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,50 @@
|
|
|
1
|
+
{# macros/icons.html — Material Symbols icon macro (with Heroicon name mapping) #}
|
|
2
|
+
{# NOTE: The icon() function is also available as a Jinja2 global (registered in core.py) #}
|
|
3
|
+
{# This macro file is kept for backward compatibility with {% from "macros/icons.html" import icon %} #}
|
|
4
|
+
|
|
5
|
+
{% set _icon_map = {
|
|
6
|
+
"home": "home",
|
|
7
|
+
"chart-bar": "bar_chart",
|
|
8
|
+
"clock": "schedule",
|
|
9
|
+
"shield-check": "verified_user",
|
|
10
|
+
"users": "group",
|
|
11
|
+
"folder": "folder",
|
|
12
|
+
"cube": "inventory_2",
|
|
13
|
+
"shopping-cart": "shopping_cart",
|
|
14
|
+
"magnifying-glass": "search",
|
|
15
|
+
"chevron-right": "chevron_right",
|
|
16
|
+
"chevron-left": "chevron_left",
|
|
17
|
+
"chevron-up": "expand_less",
|
|
18
|
+
"chevron-down": "expand_more",
|
|
19
|
+
"ellipsis-vertical": "more_vert",
|
|
20
|
+
"pencil": "edit",
|
|
21
|
+
"trash": "delete",
|
|
22
|
+
"x-mark": "close",
|
|
23
|
+
"x-circle": "cancel",
|
|
24
|
+
"check-circle": "check_circle",
|
|
25
|
+
"check": "check",
|
|
26
|
+
"plus": "add",
|
|
27
|
+
"eye": "visibility",
|
|
28
|
+
"bell": "notifications",
|
|
29
|
+
"sun": "light_mode",
|
|
30
|
+
"moon": "dark_mode",
|
|
31
|
+
"bars-": "menu",
|
|
32
|
+
"arrow-down-tray": "download",
|
|
33
|
+
"arrow-path": "refresh",
|
|
34
|
+
"paper-airplane": "send",
|
|
35
|
+
"exclamation-triangle": "warning",
|
|
36
|
+
"information-circle": "info",
|
|
37
|
+
"document-text": "description",
|
|
38
|
+
"arrow-down": "arrow_downward",
|
|
39
|
+
"arrow-up": "arrow_upward",
|
|
40
|
+
"bolt": "bolt",
|
|
41
|
+
"cog-": "settings",
|
|
42
|
+
"cog-6-tooth": "settings",
|
|
43
|
+
"lock": "lock"
|
|
44
|
+
} %}
|
|
45
|
+
|
|
46
|
+
{% macro icon(name, size="", css_class="") %}
|
|
47
|
+
{% set _ms_name = _icon_map.get(name, name) %}
|
|
48
|
+
{% set _size = size or "24px" %}
|
|
49
|
+
<span class="material-symbols-outlined {{ css_class }}" style="font-size: {{ _size }};">{{ _ms_name }}</span>
|
|
50
|
+
{% endmacro %}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{# macros/table.html — Reusable table macros for list views #}
|
|
2
|
+
|
|
3
|
+
{% macro table_header(columns, ordering, model_name, admin_path="/admin") %}
|
|
4
|
+
<thead>
|
|
5
|
+
<tr class="border-b border-[var(--color-gray-200)]">
|
|
6
|
+
<th class="px-6 py-3 text-left">
|
|
7
|
+
<input type="checkbox" id="select-all"
|
|
8
|
+
class="rounded border-[var(--color-gray-300)] text-[var(--color-primary-600)] focus:ring-[var(--color-primary-500)]">
|
|
9
|
+
</th>
|
|
10
|
+
{% for col in columns %}
|
|
11
|
+
{% if not col.primary_key or columns | length > 1 %}
|
|
12
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-[var(--color-gray-500)] uppercase tracking-wider">
|
|
13
|
+
<a href="?ordering={% if ordering == col.name %}-{% endif %}{{ col.name }}"
|
|
14
|
+
class="flex items-center gap-1 hover:text-[var(--color-gray-900)] transition-colors">
|
|
15
|
+
{{ col.name | replace('_', ' ') | title }}
|
|
16
|
+
{% if ordering == col.name %}
|
|
17
|
+
{{ icon("chevron-right") }}
|
|
18
|
+
{% elif ordering == '-' ~ col.name %}
|
|
19
|
+
{{ icon("chevron-right", class="rotate-180") }}
|
|
20
|
+
{% endif %}
|
|
21
|
+
</a>
|
|
22
|
+
</th>
|
|
23
|
+
{% endif %}
|
|
24
|
+
{% endfor %}
|
|
25
|
+
<th class="px-6 py-3 text-right text-xs font-semibold text-[var(--color-gray-500)] uppercase tracking-wider">
|
|
26
|
+
Actions
|
|
27
|
+
</th>
|
|
28
|
+
</tr>
|
|
29
|
+
</thead>
|
|
30
|
+
{% endmacro %}
|
|
31
|
+
|
|
32
|
+
{% macro table_row(obj, columns, permissions, model_name, admin_path="/admin") %}
|
|
33
|
+
<tr class="hover:bg-[var(--color-gray-50)] transition-colors">
|
|
34
|
+
<td class="px-6 py-4">
|
|
35
|
+
<input type="checkbox" name="ids[]" value="{{ obj.id }}"
|
|
36
|
+
class="rounded border-[var(--color-gray-300)] text-[var(--color-primary-600)] focus:ring-[var(--color-primary-500)]">
|
|
37
|
+
</td>
|
|
38
|
+
{% for col in columns %}
|
|
39
|
+
{% if not col.primary_key or columns | length > 1 %}
|
|
40
|
+
<td class="px-6 py-4 text-sm text-[var(--color-gray-900)]">
|
|
41
|
+
{{ table_cell(obj, col) }}
|
|
42
|
+
</td>
|
|
43
|
+
{% endif %}
|
|
44
|
+
{% endfor %}
|
|
45
|
+
<td class="px-6 py-4 text-right text-sm space-x-2">
|
|
46
|
+
{% if permissions.can_edit %}
|
|
47
|
+
<a href="{{ admin_path }}/{{ model_name }}/{{ obj.id }}"
|
|
48
|
+
class="text-[var(--color-primary-600)] hover:text-[var(--color-primary-500)] font-medium transition-colors">
|
|
49
|
+
Edit
|
|
50
|
+
</a>
|
|
51
|
+
{% endif %}
|
|
52
|
+
{% if permissions.can_delete %}
|
|
53
|
+
<button class="text-[var(--color-danger)] hover:text-red-700 font-medium transition-colors"
|
|
54
|
+
onclick="confirmAction('Delete', 'Are you sure you want to delete this?', () => { var form = document.createElement('form'); form.method = 'POST'; form.action = '{{ admin_path }}/{{ model_name }}/{{ obj.id }}/delete'; var csrf = document.createElement('input'); csrf.type = 'hidden'; csrf.name = 'csrf_token'; csrf.value = '{{ get_csrf_token(request) }}'; form.appendChild(csrf); document.body.appendChild(form); form.submit(); })">
|
|
55
|
+
Delete
|
|
56
|
+
</button>
|
|
57
|
+
{% endif %}
|
|
58
|
+
</td>
|
|
59
|
+
</tr>
|
|
60
|
+
{% endmacro %}
|
|
61
|
+
|
|
62
|
+
{% macro table_cell(obj, col) %}
|
|
63
|
+
{% if col.display_fn is defined and col.display_fn %}
|
|
64
|
+
{% set val = col.value(obj) %}
|
|
65
|
+
{% else %}
|
|
66
|
+
{% set val = attr(obj, col.name) | default('-') %}
|
|
67
|
+
{% endif %}
|
|
68
|
+
{% if col.boolean is defined and col.boolean %}
|
|
69
|
+
{% if val is sameas true %}
|
|
70
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
71
|
+
Active
|
|
72
|
+
</span>
|
|
73
|
+
{% elif val is sameas false %}
|
|
74
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
|
75
|
+
Inactive
|
|
76
|
+
</span>
|
|
77
|
+
{% else %}
|
|
78
|
+
{{ val }}
|
|
79
|
+
{% endif %}
|
|
80
|
+
{% elif val is sameas true %}
|
|
81
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
82
|
+
Active
|
|
83
|
+
</span>
|
|
84
|
+
{% elif val is sameas false %}
|
|
85
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
|
86
|
+
Inactive
|
|
87
|
+
</span>
|
|
88
|
+
{% elif val == '-' %}
|
|
89
|
+
<span class="text-[var(--color-gray-400)]">-</span>
|
|
90
|
+
{% else %}
|
|
91
|
+
{{ val }}
|
|
92
|
+
{% endif %}
|
|
93
|
+
{% endmacro %}
|
|
94
|
+
|
|
95
|
+
{% macro empty_state(model_name, verbose_name, can_create, admin_path="/admin") %}
|
|
96
|
+
<tr>
|
|
97
|
+
<td colspan="100" class="px-6 py-12 text-center">
|
|
98
|
+
{{ icon("document-text", size="48px") }}
|
|
99
|
+
<p class="mt-4 text-sm text-[var(--color-gray-500)]">No {{ verbose_name | lower }} found</p>
|
|
100
|
+
{% if can_create %}
|
|
101
|
+
<a href="{{ admin_path }}/{{ model_name }}/create"
|
|
102
|
+
class="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-primary-600)] text-white text-sm font-medium hover:bg-[var(--color-primary-500)] transition-colors">
|
|
103
|
+
+ Create your first {{ verbose_name | lower }}
|
|
104
|
+
</a>
|
|
105
|
+
{% endif %}
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
{% endmacro %}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
{# Widget macros for form rendering #}
|
|
2
|
+
|
|
3
|
+
{% macro text_input(name, label, value='', required=false, readonly=false, placeholder='', maxlength=null, error=null, css_class='') %}
|
|
4
|
+
<div class="relative">
|
|
5
|
+
<input type="text"
|
|
6
|
+
id="{{ name }}"
|
|
7
|
+
name="{{ name }}"
|
|
8
|
+
value="{{ value or '' }}"
|
|
9
|
+
{% if required %}required{% endif %}
|
|
10
|
+
{% if readonly %}readonly{% endif %}
|
|
11
|
+
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
|
12
|
+
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
|
13
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
14
|
+
</div>
|
|
15
|
+
{% endmacro %}
|
|
16
|
+
|
|
17
|
+
{% macro textarea(name, label, value='', required=false, readonly=false, rows=5, placeholder='', error=null, css_class='') %}
|
|
18
|
+
<textarea id="{{ name }}"
|
|
19
|
+
name="{{ name }}"
|
|
20
|
+
rows="{{ rows }}"
|
|
21
|
+
{% if required %}required{% endif %}
|
|
22
|
+
{% if readonly %}readonly{% endif %}
|
|
23
|
+
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
|
24
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">{{ value or '' }}</textarea>
|
|
25
|
+
{% endmacro %}
|
|
26
|
+
|
|
27
|
+
{% macro number_input(name, label, value='', required=false, readonly=false, step='1', min=null, max=null, placeholder='', error=null, css_class='') %}
|
|
28
|
+
<input type="number"
|
|
29
|
+
id="{{ name }}"
|
|
30
|
+
name="{{ name }}"
|
|
31
|
+
value="{{ value or '' }}"
|
|
32
|
+
step="{{ step }}"
|
|
33
|
+
{% if min is not none %}min="{{ min }}"{% endif %}
|
|
34
|
+
{% if max is not none %}max="{{ max }}"{% endif %}
|
|
35
|
+
{% if required %}required{% endif %}
|
|
36
|
+
{% if readonly %}readonly{% endif %}
|
|
37
|
+
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
|
38
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
39
|
+
{% endmacro %}
|
|
40
|
+
|
|
41
|
+
{% macro toggle(name, label, checked=false, required=false, readonly=false, error=null, css_class='') %}
|
|
42
|
+
<label class="relative inline-flex items-center cursor-pointer">
|
|
43
|
+
<input type="checkbox"
|
|
44
|
+
id="{{ name }}"
|
|
45
|
+
name="{{ name }}"
|
|
46
|
+
{% if checked %}checked{% endif %}
|
|
47
|
+
{% if readonly %}disabled{% endif %}
|
|
48
|
+
class="sr-only peer">
|
|
49
|
+
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
|
50
|
+
</label>
|
|
51
|
+
{% endmacro %}
|
|
52
|
+
|
|
53
|
+
{% macro date_picker(name, label, value='', required=false, readonly=false, error=null, css_class='') %}
|
|
54
|
+
<input type="date"
|
|
55
|
+
id="{{ name }}"
|
|
56
|
+
name="{{ name }}"
|
|
57
|
+
value="{{ value or '' }}"
|
|
58
|
+
{% if required %}required{% endif %}
|
|
59
|
+
{% if readonly %}readonly{% endif %}
|
|
60
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
61
|
+
{% endmacro %}
|
|
62
|
+
|
|
63
|
+
{% macro datetime_picker(name, label, value='', required=false, readonly=false, error=null, css_class='') %}
|
|
64
|
+
<input type="datetime-local"
|
|
65
|
+
id="{{ name }}"
|
|
66
|
+
name="{{ name }}"
|
|
67
|
+
value="{{ value or '' }}"
|
|
68
|
+
{% if required %}required{% endif %}
|
|
69
|
+
{% if readonly %}readonly{% endif %}
|
|
70
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
71
|
+
{% endmacro %}
|
|
72
|
+
|
|
73
|
+
{% macro time_picker(name, label, value='', required=false, readonly=false, error=null, css_class='') %}
|
|
74
|
+
<input type="time"
|
|
75
|
+
id="{{ name }}"
|
|
76
|
+
name="{{ name }}"
|
|
77
|
+
value="{{ value or '' }}"
|
|
78
|
+
{% if required %}required{% endif %}
|
|
79
|
+
{% if readonly %}readonly{% endif %}
|
|
80
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
81
|
+
{% endmacro %}
|
|
82
|
+
|
|
83
|
+
{% macro select(name, label, value='', choices=[], required=false, readonly=false, error=null, css_class='') %}
|
|
84
|
+
<select id="{{ name }}"
|
|
85
|
+
name="{{ name }}"
|
|
86
|
+
{% if required %}required{% endif %}
|
|
87
|
+
{% if readonly %}disabled{% endif %}
|
|
88
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
89
|
+
<option value="">-- Select --</option>
|
|
90
|
+
{% for choice in choices %}
|
|
91
|
+
<option value="{{ choice.value }}" {% if choice.value == value %}selected{% endif %}>
|
|
92
|
+
{{ choice.label }}
|
|
93
|
+
</option>
|
|
94
|
+
{% endfor %}
|
|
95
|
+
</select>
|
|
96
|
+
{% endmacro %}
|
|
97
|
+
|
|
98
|
+
{% macro json_editor(name, label, value='', required=false, readonly=false, error=null, css_class='') %}
|
|
99
|
+
<textarea id="{{ name }}"
|
|
100
|
+
name="{{ name }}"
|
|
101
|
+
rows="10"
|
|
102
|
+
{% if required %}required{% endif %}
|
|
103
|
+
{% if readonly %}readonly{% endif %}
|
|
104
|
+
class="w-full border rounded px-3 py-2 font-mono text-sm {% if error %}border-red-500{% endif %} {{ css_class }}"
|
|
105
|
+
placeholder="{}">{{ value | tojson if value else '' }}</textarea>
|
|
106
|
+
{% endmacro %}
|
|
107
|
+
|
|
108
|
+
{% macro file_upload(name, label, required=false, readonly=false, accept='*/*', error=null, css_class='') %}
|
|
109
|
+
<input type="file"
|
|
110
|
+
id="{{ name }}"
|
|
111
|
+
name="{{ name }}"
|
|
112
|
+
accept="{{ accept }}"
|
|
113
|
+
{% if required %}required{% endif %}
|
|
114
|
+
{% if readonly %}disabled{% endif %}
|
|
115
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
116
|
+
{% endmacro %}
|
|
117
|
+
|
|
118
|
+
{% macro tag_input(name, label, tags=[], required=false, readonly=false, error=null, css_class='') %}
|
|
119
|
+
<div x-data="{ tags: {{ tags | tojson }}, newTag: '' }" class="w-full">
|
|
120
|
+
<div class="flex flex-wrap gap-2 mb-2">
|
|
121
|
+
<template x-for="(tag, index) in tags" :key="index">
|
|
122
|
+
<span class="bg-blue-100 text-blue-800 text-sm px-2 py-1 rounded flex items-center gap-1">
|
|
123
|
+
<span x-text="tag"></span>
|
|
124
|
+
{% if not readonly %}
|
|
125
|
+
<button type="button" @click="tags.splice(index, 1)" class="text-blue-600 hover:text-blue-800">×</button>
|
|
126
|
+
{% endif %}
|
|
127
|
+
</span>
|
|
128
|
+
</template>
|
|
129
|
+
</div>
|
|
130
|
+
<input type="text"
|
|
131
|
+
x-model="newTag"
|
|
132
|
+
@keydown.enter.prevent="if(newTag.trim()){tags.push(newTag.trim());newTag=''}"
|
|
133
|
+
placeholder="Add tag..."
|
|
134
|
+
class="w-full border rounded px-3 py-2">
|
|
135
|
+
<input type="hidden" :name="'{{ name }}'" :value="JSON.stringify(tags)">
|
|
136
|
+
</div>
|
|
137
|
+
{% endmacro %}
|
|
138
|
+
|
|
139
|
+
{% macro relation_picker(name, label, value='', required=false, readonly=false, error=null, css_class='') %}
|
|
140
|
+
<select id="{{ name }}"
|
|
141
|
+
name="{{ name }}"
|
|
142
|
+
{% if required %}required{% endif %}
|
|
143
|
+
{% if readonly %}disabled{% endif %}
|
|
144
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
145
|
+
<option value="">-- Select --</option>
|
|
146
|
+
{# Options will be loaded via HTMX #}
|
|
147
|
+
</select>
|
|
148
|
+
{% endmacro %}
|
|
149
|
+
|
|
150
|
+
{% macro multi_relation_picker(name, label, selected=[], required=false, readonly=false, error=null, css_class='') %}
|
|
151
|
+
<select id="{{ name }}"
|
|
152
|
+
name="{{ name }}"
|
|
153
|
+
multiple
|
|
154
|
+
{% if required %}required{% endif %}
|
|
155
|
+
{% if readonly %}disabled{% endif %}
|
|
156
|
+
class="w-full border rounded px-3 py-2 {% if error %}border-red-500{% endif %} {{ css_class }}">
|
|
157
|
+
{# Options will be loaded via HTMX #}
|
|
158
|
+
</select>
|
|
159
|
+
{% endmacro %}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
{# pages/2fa/setup.html — 2FA setup with QR code #}
|
|
2
|
+
{% extends "base.html" %}
|
|
3
|
+
|
|
4
|
+
{% block title %}Two-Factor Authentication — {{ title | default("Admin") }}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block breadcrumb %}
|
|
7
|
+
{{ super() }}
|
|
8
|
+
<span class="text-[var(--color-gray-400)]">/</span>
|
|
9
|
+
<a href="{{ admin_path | default('/admin') }}/profile" class="hover:text-[var(--color-primary-600)] transition-colors">Profile</a>
|
|
10
|
+
<span class="text-[var(--color-gray-400)]">/</span>
|
|
11
|
+
<span class="text-[var(--color-gray-900)] font-medium">Two-Factor Authentication</span>
|
|
12
|
+
{% endblock %}
|
|
13
|
+
|
|
14
|
+
{% block content %}
|
|
15
|
+
<div class="space-y-6">
|
|
16
|
+
<div class="page-header">
|
|
17
|
+
<div class="page-header-left">
|
|
18
|
+
<a href="{{ admin_path | default('/admin') }}/profile" class="page-header-back">
|
|
19
|
+
{{ icon("chevron-left") }}
|
|
20
|
+
Profile
|
|
21
|
+
</a>
|
|
22
|
+
<h1 class="page-header-title">Two-Factor Authentication</h1>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
{% if error %}
|
|
27
|
+
<div class="p-4 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
|
|
28
|
+
{{ error }}
|
|
29
|
+
</div>
|
|
30
|
+
{% endif %}
|
|
31
|
+
|
|
32
|
+
{% if success %}
|
|
33
|
+
<div class="p-4 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
|
34
|
+
{{ success }}
|
|
35
|
+
</div>
|
|
36
|
+
{% endif %}
|
|
37
|
+
|
|
38
|
+
{% if backup_codes %}
|
|
39
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
|
|
40
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-2">Backup Codes</h2>
|
|
41
|
+
<p class="text-sm text-[var(--color-gray-600)] mb-4">
|
|
42
|
+
Save these codes somewhere safe. Each code can only be used once.
|
|
43
|
+
</p>
|
|
44
|
+
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
45
|
+
{% for code in backup_codes %}
|
|
46
|
+
<code class="px-3 py-2 bg-[var(--color-gray-50)] border border-[var(--color-gray-200)] rounded text-sm font-mono text-center">
|
|
47
|
+
{{ code }}
|
|
48
|
+
</code>
|
|
49
|
+
{% endfor %}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
{% endif %}
|
|
53
|
+
|
|
54
|
+
{% if totp_enabled %}
|
|
55
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
|
|
56
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-2">2FA is Enabled</h2>
|
|
57
|
+
<p class="text-sm text-[var(--color-gray-600)] mb-4">Your account is protected with two-factor authentication.</p>
|
|
58
|
+
|
|
59
|
+
<div class="flex items-center gap-4">
|
|
60
|
+
<form method="post" action="{{ admin_path | default('/admin') }}/profile/2fa/disable" class="inline">
|
|
61
|
+
<input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
|
|
62
|
+
<div class="flex items-center gap-2">
|
|
63
|
+
<input type="password" name="password" placeholder="Current password" required
|
|
64
|
+
class="form-input px-3 py-1.5 rounded-lg border border-[var(--color-gray-300)] text-sm">
|
|
65
|
+
<input type="text" name="code" placeholder="TOTP code" required maxlength="6" pattern="[0-9]{6}"
|
|
66
|
+
class="form-input px-3 py-1.5 rounded-lg border border-[var(--color-gray-300)] text-sm w-24">
|
|
67
|
+
<button type="submit"
|
|
68
|
+
class="px-4 py-1.5 rounded-lg border border-red-300 text-red-700 text-sm hover:bg-red-50 transition-colors">
|
|
69
|
+
Disable 2FA
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</form>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<form method="post" action="{{ admin_path | default('/admin') }}/profile/2fa/backup-codes" class="mt-4">
|
|
76
|
+
<input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
|
|
77
|
+
<button type="submit"
|
|
78
|
+
class="px-4 py-2 rounded-lg border border-[var(--color-gray-300)] text-sm text-[var(--color-gray-700)] hover:bg-[var(--color-gray-50)] transition-colors">
|
|
79
|
+
Regenerate Backup Codes
|
|
80
|
+
</button>
|
|
81
|
+
</form>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{% elif secret %}
|
|
85
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
|
|
86
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-2">Set Up 2FA</h2>
|
|
87
|
+
<p class="text-sm text-[var(--color-gray-600)] mb-4">
|
|
88
|
+
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.).
|
|
89
|
+
</p>
|
|
90
|
+
|
|
91
|
+
<div class="flex items-start gap-8">
|
|
92
|
+
<div class="text-center">
|
|
93
|
+
{% if qr_uri %}
|
|
94
|
+
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={{ qr_uri | urlencode }}"
|
|
95
|
+
alt="TOTP QR Code" class="border border-[var(--color-gray-200)] rounded-lg">
|
|
96
|
+
{% endif %}
|
|
97
|
+
<p class="mt-2 text-xs text-[var(--color-gray-500)]">Or enter manually:</p>
|
|
98
|
+
<code class="text-xs font-mono break-all text-[var(--color-gray-600)]">{{ secret }}</code>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="flex-1">
|
|
102
|
+
<form method="post" action="{{ admin_path | default('/admin') }}/profile/2fa/enable">
|
|
103
|
+
<input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
|
|
104
|
+
<div class="mb-4">
|
|
105
|
+
<label for="code" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">
|
|
106
|
+
Enter the 6-digit code from your app
|
|
107
|
+
</label>
|
|
108
|
+
<input type="text" id="code" name="code" required maxlength="6" pattern="[0-9]{6}"
|
|
109
|
+
class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors"
|
|
110
|
+
placeholder="000000" autocomplete="off">
|
|
111
|
+
</div>
|
|
112
|
+
<button type="submit"
|
|
113
|
+
class="btn-primary px-4 py-2 rounded-lg bg-[var(--color-primary-600)] text-white text-sm font-medium hover:bg-[var(--color-primary-500)] transition-colors">
|
|
114
|
+
Enable 2FA
|
|
115
|
+
</button>
|
|
116
|
+
</form>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
{% endif %}
|
|
121
|
+
</div>
|
|
122
|
+
{% endblock %}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{# pages/2fa/verify.html — 2FA verification during login #}
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>{{ title | default("Admin") }} — Verify 2FA</title>
|
|
8
|
+
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
12
|
+
|
|
13
|
+
<link rel="stylesheet" href="/static/css/tokens.css">
|
|
14
|
+
<link rel="stylesheet" href="/static/css/admin.css">
|
|
15
|
+
</head>
|
|
16
|
+
<body class="bg-[var(--surface-overlay)] text-[var(--text-primary)] font-sans antialiased min-h-screen flex items-center justify-center p-4">
|
|
17
|
+
|
|
18
|
+
<div class="w-full max-w-md">
|
|
19
|
+
<div class="text-center mb-6">
|
|
20
|
+
<div class="w-10 h-10 mx-auto mb-2 rounded-lg bg-[var(--primary-500)] flex items-center justify-center">
|
|
21
|
+
{{ icon("lock", css_class="text-white", size="20px") }}
|
|
22
|
+
</div>
|
|
23
|
+
<h1 class="text-xl font-bold text-[var(--text-primary)]" style="margin: 0;">Verify Your Identity</h1>
|
|
24
|
+
<p class="text-sm text-[var(--text-secondary)] mt-0.5">Enter the code from your authenticator app</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<form method="post" action="{{ admin_path | default('/admin') }}/verify-2fa"
|
|
28
|
+
class="bg-white rounded-xl shadow-md border border-[var(--surface-border)] p-8">
|
|
29
|
+
<input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
|
|
30
|
+
{% if temp_token %}
|
|
31
|
+
<input type="hidden" name="temp_token" value="{{ temp_token }}">
|
|
32
|
+
{% endif %}
|
|
33
|
+
|
|
34
|
+
{% if error %}
|
|
35
|
+
<div class="mb-4 p-3 rounded-lg bg-[var(--danger-50)] text-[var(--danger-500)] text-sm border border-[var(--danger-100)]">
|
|
36
|
+
{{ error }}
|
|
37
|
+
</div>
|
|
38
|
+
{% endif %}
|
|
39
|
+
|
|
40
|
+
<div class="mb-6">
|
|
41
|
+
<label for="code" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">Authentication Code</label>
|
|
42
|
+
<input type="text" id="code" name="code" required maxlength="6" pattern="[0-9]{6}"
|
|
43
|
+
class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--surface-border)] bg-white text-[var(--text-primary)] placeholder-[var(--text-disabled)] focus:ring-2 focus:ring-[var(--primary-500)] focus:border-[var(--primary-500)] transition-all text-center text-2xl tracking-[0.5em] font-mono"
|
|
44
|
+
placeholder="000000" autocomplete="off" autofocus>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<button type="submit"
|
|
48
|
+
class="w-full px-4 py-3 rounded-lg bg-[var(--primary-500)] text-white font-medium hover:bg-[var(--primary-600)] focus:ring-2 focus:ring-[var(--primary-500)] focus:ring-offset-2 transition-all cursor-pointer">
|
|
49
|
+
Verify
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
{# pages/audit_detail.html — single audit entry with full diff #}
|
|
2
|
+
{% extends "base.html" %}
|
|
3
|
+
|
|
4
|
+
{% block title %}Audit Entry #{{ entry.id }} — {{ title | default("Admin") }}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block breadcrumb %}
|
|
7
|
+
{{ super() }}
|
|
8
|
+
<span class="text-[var(--color-gray-400)]">/</span>
|
|
9
|
+
<a href="{{ admin_path | default('/admin') }}/audit-log/" class="hover:text-[var(--color-primary-600)] transition-colors">Audit Log</a>
|
|
10
|
+
<span class="text-[var(--color-gray-400)]">/</span>
|
|
11
|
+
<span class="text-[var(--color-gray-900)] font-medium">#{{ entry.id }}</span>
|
|
12
|
+
{% endblock %}
|
|
13
|
+
|
|
14
|
+
{% block content %}
|
|
15
|
+
<div class="space-y-6">
|
|
16
|
+
<div class="page-header">
|
|
17
|
+
<div class="page-header-left">
|
|
18
|
+
<a href="{{ admin_path | default('/admin') }}/audit-log/" class="page-header-back">
|
|
19
|
+
{{ icon("chevron-left") }}
|
|
20
|
+
Audit Log
|
|
21
|
+
</a>
|
|
22
|
+
<h1 class="page-header-title">Audit Entry <span class="mono">#{{ entry.id }}</span></h1>
|
|
23
|
+
<p class="page-header-subtitle">
|
|
24
|
+
{{ entry.model_name }} — {{ entry.object_repr | default("") }}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="page-header-right">
|
|
28
|
+
{% if entry.action == 'CREATE' %}
|
|
29
|
+
<span class="badge badge-success">Created</span>
|
|
30
|
+
{% elif entry.action == 'UPDATE' %}
|
|
31
|
+
<span class="badge badge-warning">Updated</span>
|
|
32
|
+
{% elif entry.action == 'DELETE' %}
|
|
33
|
+
<span class="badge badge-danger">Deleted</span>
|
|
34
|
+
{% endif %}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)]">
|
|
39
|
+
<div class="px-6 py-4 border-b border-[var(--color-gray-200)]">
|
|
40
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)]">Details</h2>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
43
|
+
<div>
|
|
44
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">Action</dt>
|
|
45
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.action }}</dd>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">Timestamp</dt>
|
|
49
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.timestamp }}</dd>
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">User</dt>
|
|
53
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.user_email | default("System") }}</dd>
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">IP Address</dt>
|
|
57
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.ip_address | default("-") }}</dd>
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">Model</dt>
|
|
61
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.model_name }}</dd>
|
|
62
|
+
</div>
|
|
63
|
+
<div>
|
|
64
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">Object ID</dt>
|
|
65
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.object_id }}</dd>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="md:col-span-2">
|
|
68
|
+
<dt class="text-sm font-medium text-[var(--color-gray-500)]">Object Representation</dt>
|
|
69
|
+
<dd class="mt-1 text-sm text-[var(--color-gray-900)]">{{ entry.object_repr | default("-") }}</dd>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{% if entry.changes %}
|
|
75
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)]">
|
|
76
|
+
<div class="px-6 py-4 border-b border-[var(--color-gray-200)]">
|
|
77
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)]">Changes</h2>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="p-6">
|
|
80
|
+
<table class="w-full text-sm">
|
|
81
|
+
<thead>
|
|
82
|
+
<tr class="border-b border-[var(--color-gray-200)]">
|
|
83
|
+
<th class="text-left py-2 font-medium text-[var(--color-gray-500)]">Field</th>
|
|
84
|
+
<th class="text-left py-2 font-medium text-[var(--color-gray-500)]">Before</th>
|
|
85
|
+
<th class="text-left py-2 font-medium text-[var(--color-gray-500)]">After</th>
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody class="divide-y divide-[var(--color-gray-100)]">
|
|
89
|
+
{% for field, change in entry.changes.items() %}
|
|
90
|
+
<tr>
|
|
91
|
+
<td class="py-2 font-medium text-[var(--color-gray-900)]">{{ field }}</td>
|
|
92
|
+
<td class="py-2 text-[var(--color-gray-600)]">{{ change.before | default("-") }}</td>
|
|
93
|
+
<td class="py-2 text-[var(--color-gray-900)]">{{ change.after | default("-") }}</td>
|
|
94
|
+
</tr>
|
|
95
|
+
{% endfor %}
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{% endif %}
|
|
101
|
+
|
|
102
|
+
{% if entry.full_snapshot %}
|
|
103
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)]">
|
|
104
|
+
<div class="px-6 py-4 border-b border-[var(--color-gray-200)]">
|
|
105
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)]">Full Snapshot</h2>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="p-6">
|
|
108
|
+
<pre class="bg-[var(--color-gray-50)] rounded-lg p-4 overflow-auto text-sm text-[var(--color-gray-900)]">{{ entry.full_snapshot | tojson(indent=2) }}</pre>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
{% endif %}
|
|
112
|
+
|
|
113
|
+
<div class="bg-white rounded-xl border border-[var(--color-gray-200)]">
|
|
114
|
+
<div class="px-6 py-4 border-b border-[var(--color-gray-200)]">
|
|
115
|
+
<h2 class="text-lg font-semibold text-[var(--color-gray-900)]">User Agent</h2>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="p-6">
|
|
118
|
+
<p class="text-sm text-[var(--color-gray-600)] break-all">{{ entry.user_agent | default("-") }}</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
{% endblock %}
|