django-adminflow 1.0.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 (47) hide show
  1. adminflow/__init__.py +1 -0
  2. adminflow/apps.py +22 -0
  3. adminflow/context_processors.py +72 -0
  4. adminflow/forms.py +89 -0
  5. adminflow/import_export.py +224 -0
  6. adminflow/static/adminflow/img/logo.png +0 -0
  7. adminflow/templates/admin/actions.html +79 -0
  8. adminflow/templates/admin/base.html +1263 -0
  9. adminflow/templates/admin/base_site.html +5 -0
  10. adminflow/templates/admin/change_form.html +315 -0
  11. adminflow/templates/admin/change_list.html +216 -0
  12. adminflow/templates/admin/change_list_results.html +211 -0
  13. adminflow/templates/admin/delete_confirmation.html +55 -0
  14. adminflow/templates/admin/delete_selected_confirmation.html +118 -0
  15. adminflow/templates/admin/edit_inline/stacked.html +85 -0
  16. adminflow/templates/admin/edit_inline/tabular.html +81 -0
  17. adminflow/templates/admin/filter.html +17 -0
  18. adminflow/templates/admin/import_export/change_list_export_item.html +8 -0
  19. adminflow/templates/admin/import_export/change_list_import_item.html +8 -0
  20. adminflow/templates/admin/import_export/export.html +199 -0
  21. adminflow/templates/admin/includes/fieldset.html +73 -0
  22. adminflow/templates/admin/includes/toast.html +102 -0
  23. adminflow/templates/admin/index.html +227 -0
  24. adminflow/templates/admin/logged_out.html +38 -0
  25. adminflow/templates/admin/login.html +144 -0
  26. adminflow/templates/admin/object_history.html +60 -0
  27. adminflow/templates/admin/pagination.html +30 -0
  28. adminflow/templates/admin/search_form.html +28 -0
  29. adminflow/templates/admin/submit_line.html +48 -0
  30. adminflow/templates/admin/verify_2fa.html +280 -0
  31. adminflow/templates/otp/email/token.html +64 -0
  32. adminflow/templates/otp/email/token.txt +11 -0
  33. adminflow/templates/registration/logged_out.html +38 -0
  34. adminflow/templates/registration/password_change_form.html +272 -0
  35. adminflow/templates/simple_history/object_history.html +186 -0
  36. adminflow/templates/simple_history/object_history_form.html +103 -0
  37. adminflow/templates/simple_history/submit_line.html +17 -0
  38. adminflow/templatetags/__init__.py +1 -0
  39. adminflow/templatetags/__pycache__/__init__.cpython-314.pyc +0 -0
  40. adminflow/templatetags/__pycache__/adminflow_tags.cpython-314.pyc +0 -0
  41. adminflow/templatetags/adminflow_tags.py +233 -0
  42. adminflow/user_admin.py +662 -0
  43. adminflow/views.py +207 -0
  44. django_adminflow-1.0.0.dist-info/METADATA +398 -0
  45. django_adminflow-1.0.0.dist-info/RECORD +47 -0
  46. django_adminflow-1.0.0.dist-info/WHEEL +5 -0
  47. django_adminflow-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,144 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% load i18n static %}
3
+
4
+ {% block bodyclass %}bg-background h-full flex items-center justify-center p-4 md:p-8{% endblock %}
5
+
6
+ {% block messages %}{% endblock %}
7
+
8
+ {% block content %}
9
+ <main class="w-full max-w-5xl h-[650px] flex overflow-hidden rounded-xl shadow-lg bg-surface-lowest border border-outline-variant mx-auto my-[5vh]">
10
+ <!-- Left Side: Decorative Enterprise panel -->
11
+ <section class="hidden md:flex flex-1 bg-gradient-to-br from-primary to-inverse-surface relative items-center justify-center p-12 overflow-hidden">
12
+ <div class="absolute top-[-10%] left-[-10%] w-64 h-64 bg-primary/20 rounded-full blur-3xl"></div>
13
+ <div class="absolute bottom-[-10%] right-[-10%] w-96 h-96 bg-success/10 rounded-full blur-3xl"></div>
14
+ <div class="relative z-10 text-center space-y-6">
15
+ <div class="inline-flex items-center justify-center p-5 rounded-2xl bg-white/10 backdrop-blur-md border border-white/20 mb-2 shadow-xl">
16
+ {% if adminflow_logo_icon %}
17
+ <span class="material-symbols-outlined text-white text-5xl">{{ adminflow_logo_icon }}</span>
18
+ {% elif adminflow_logo %}
19
+ <img src="{{ adminflow_logo }}" alt="{{ adminflow_title }}" class="h-12 w-auto max-w-[140px] object-contain filter brightness-0 invert"/>
20
+ {% else %}
21
+ <svg class="h-12 w-12 text-white fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
22
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
23
+ </svg>
24
+ {% endif %}
25
+ </div>
26
+ <h1 class="text-3xl font-extrabold text-white leading-tight font-sans">{{ adminflow_login_title|default:"Master Your Enterprise Flow" }}</h1>
27
+ <p class="text-xs text-white/80 max-w-sm mx-auto leading-relaxed">
28
+ {{ adminflow_login_subtitle|default:"A unified administrative engine built for scale, security, and developer productivity within the Django ecosystem." }}
29
+ </p>
30
+ </div>
31
+
32
+ <div class="absolute inset-0 opacity-10 pointer-events-none">
33
+ <svg height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
34
+ <path d="M-100 100 Q 200 300 600 100 T 1200 400" fill="none" stroke="white" stroke-width="2"></path>
35
+ <path d="M-100 400 Q 300 100 700 500 T 1300 200" fill="none" stroke="white" stroke-width="1.5"></path>
36
+ </svg>
37
+ </div>
38
+ </section>
39
+
40
+ <!-- Right Side: Login Form -->
41
+ <section class="flex-1 flex flex-col items-center justify-center p-8 md:p-12 bg-surface">
42
+ <div class="w-full max-w-sm space-y-6">
43
+ <!-- Brand & Header -->
44
+ <div class="text-center space-y-2">
45
+ <div class="flex justify-center">
46
+ {% if adminflow_logo %}
47
+ <img src="{{ adminflow_logo }}" alt="{{ adminflow_title }}" class="h-12 w-auto"/>
48
+ {% else %}
49
+ <div class="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-white shadow-md">
50
+ <span class="material-symbols-outlined text-2xl">hub</span>
51
+ </div>
52
+ {% endif %}
53
+ </div>
54
+ <div>
55
+ <h2 class="text-xl font-bold text-on-surface">Welcome back</h2>
56
+ <p class="text-xs text-on-surface-variant">Sign in to manage {{ adminflow_title }}</p>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Form -->
61
+ <form action="{{ app_path }}" method="post" id="login-form" class="space-y-4">
62
+ {% csrf_token %}
63
+
64
+ <!-- Username -->
65
+ <div class="space-y-1">
66
+ <label class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider" for="id_username">{% translate "Username" %}</label>
67
+ <input type="text" name="username" autofocus required id="id_username"
68
+ class="w-full h-11 px-3 rounded-lg border border-outline-variant bg-surface-lowest text-on-surface text-xs focus:ring-2 focus:ring-primary/15 focus:border-primary transition-all outline-none"
69
+ placeholder="admin"/>
70
+ {% if form.username.errors %}
71
+ <p class="text-[11px] text-danger">{{ form.username.errors|join:", " }}</p>
72
+ {% endif %}
73
+ </div>
74
+
75
+ <!-- Password -->
76
+ <div class="space-y-1">
77
+ <div class="flex justify-between items-center">
78
+ <label class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider" for="id_password">{% translate "Password" %}</label>
79
+ {% if password_reset_url %}
80
+ <a href="{{ password_reset_url }}" class="text-[10px] font-bold text-primary hover:underline transition-all">Forgot?</a>
81
+ {% endif %}
82
+ </div>
83
+ <div class="relative">
84
+ <input type="password" name="password" required id="id_password"
85
+ class="w-full h-11 px-3 pr-10 rounded-lg border border-outline-variant bg-surface-lowest text-on-surface text-xs focus:ring-2 focus:ring-primary/15 focus:border-primary transition-all outline-none"
86
+ placeholder="••••••••"/>
87
+ <button type="button" onclick="togglePasswordVisibility()" class="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant hover:text-on-surface transition-colors focus:outline-none">
88
+ <span class="material-symbols-outlined text-[18px]" id="visibilityIcon">visibility</span>
89
+ </button>
90
+ </div>
91
+ {% if form.password.errors %}
92
+ <p class="text-[11px] text-danger">{{ form.password.errors|join:", " }}</p>
93
+ {% endif %}
94
+ </div>
95
+
96
+ <input type="hidden" name="next" value="{{ next }}"/>
97
+
98
+ <!-- Sign In Button -->
99
+ <button type="submit" id="btn-signin" class="w-full h-11 mt-2 bg-primary text-white rounded-lg text-xs font-bold hover:bg-primary/95 active:scale-[0.99] transition-all shadow-md flex items-center justify-center gap-2 cursor-pointer">
100
+ <span id="btn-signin-text">{% translate "Sign In" %}</span>
101
+ </button>
102
+ </form>
103
+ </div>
104
+ </section>
105
+ </main>
106
+
107
+ <!-- Floating Toast Notifications -->
108
+ {% include "admin/includes/toast.html" %}
109
+
110
+ <script>
111
+ function togglePasswordVisibility() {
112
+ const passwordInput = document.getElementById('id_password');
113
+ const icon = document.getElementById('visibilityIcon');
114
+ if (passwordInput && icon) {
115
+ if (passwordInput.type === 'password') {
116
+ passwordInput.type = 'text';
117
+ icon.textContent = 'visibility_off';
118
+ } else {
119
+ passwordInput.type = 'password';
120
+ icon.textContent = 'visibility';
121
+ }
122
+ }
123
+ }
124
+
125
+ document.addEventListener('DOMContentLoaded', function() {
126
+ const loginForm = document.getElementById('login-form');
127
+ const signinBtn = document.getElementById('btn-signin');
128
+
129
+ if (loginForm && signinBtn) {
130
+ loginForm.addEventListener('submit', function() {
131
+ signinBtn.disabled = true;
132
+ signinBtn.classList.add('opacity-75', 'cursor-not-allowed', 'pointer-events-none');
133
+ signinBtn.innerHTML = `
134
+ <svg class="animate-spin h-4 w-4 text-white inline-block shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
135
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
136
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
137
+ </svg>
138
+ <span>Signing In...</span>
139
+ `;
140
+ });
141
+ }
142
+ });
143
+ </script>
144
+ {% endblock %}
@@ -0,0 +1,60 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% load i18n admin_urls %}
3
+
4
+ {% block breadcrumbs %}
5
+ <nav class="flex items-center gap-1.5 text-xs text-on-surface-variant/70 mb-4">
6
+ <a href="{% url 'admin:index' %}" class="hover:text-primary transition-colors flex items-center"><span class="material-symbols-outlined text-[16px]">home</span></a>
7
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
8
+ <a href="{% url 'admin:app_list' app_label=opts.app_label %}" class="hover:text-primary transition-colors capitalize font-medium">{{ opts.app_config.verbose_name }}</a>
9
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
10
+ <a href="{% url opts|admin_urlname:'changelist' %}" class="hover:text-primary transition-colors font-bold">{{ opts.verbose_name_plural|capfirst }}</a>
11
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
12
+ {% if object %}
13
+ <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}" class="hover:text-primary transition-colors font-bold">{{ object|truncatewords:"18" }}</a>
14
+ {% else %}
15
+ <span class="font-bold text-on-surface-variant">{{ object_id }}</span>
16
+ {% endif %}
17
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
18
+ <span class="text-primary font-bold">{% translate 'History' %}</span>
19
+ </nav>
20
+ {% endblock %}
21
+
22
+ {% block content %}
23
+ <div class="space-y-6 max-w-4xl mx-auto w-full">
24
+ <!-- Header Block -->
25
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
26
+ <div>
27
+ <h2 class="text-xl md:text-2xl font-black text-on-surface tracking-tight">
28
+ {% if object %}{{ object|truncatewords:"10" }}{% else %}{{ object_id }}{% endif %} {% translate 'History' %}
29
+ </h2>
30
+ <p class="text-xs text-on-surface-variant mt-1">{% translate 'Overview of modifications and updates tracked on this object.' %}</p>
31
+ </div>
32
+ </div>
33
+
34
+ <!-- Core Model History Content Tab -->
35
+ <div class="module">
36
+ {% if action_list %}
37
+ <table id="change-history">
38
+ <thead>
39
+ <tr>
40
+ <th scope="col">{% translate 'Date/time' %}</th>
41
+ <th scope="col">{% translate 'User' %}</th>
42
+ <th scope="col">{% translate 'Action' %}</th>
43
+ </tr>
44
+ </thead>
45
+ <tbody>
46
+ {% for action in action_list %}
47
+ <tr>
48
+ <th scope="row" class="font-normal">{{ action.action_time }}</th>
49
+ <td>{{ action.user.get_username }}</td>
50
+ <td>{{ action.change_message }}</td>
51
+ </tr>
52
+ {% endfor %}
53
+ </tbody>
54
+ </table>
55
+ {% else %}
56
+ <p class="text-sm text-on-surface-variant">{% trans "This object doesn't have a change history." %}</p>
57
+ {% endif %}
58
+ </div>
59
+ </div>
60
+ {% endblock %}
@@ -0,0 +1,30 @@
1
+ {% load i18n %}
2
+ <div class="px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-outline-variant bg-surface-low/50">
3
+ <!-- Results summary info -->
4
+ <div class="text-xs text-on-surface-variant font-medium">
5
+ <span>
6
+ {{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %}
7
+ </span>
8
+ {% if show_all_url %}
9
+ <a href="{{ show_all_url }}" class="ml-2 text-primary hover:underline font-bold">{% translate "Show all" %}</a>
10
+ {% endif %}
11
+ </div>
12
+
13
+ <!-- Pager buttons -->
14
+ {% if pagination_required %}
15
+ <nav class="flex items-center gap-1.5" aria-label="{% translate 'Pagination' %}">
16
+ {% for i in page_range %}
17
+ {% if i == '.' %}
18
+ <span class="px-2 py-1 text-xs text-on-surface-variant font-bold">...</span>
19
+ {% elif i == cl.page_num %}
20
+ <span class="px-3 py-1.5 bg-primary text-white text-xs font-extrabold rounded-lg shadow-sm">{{ i }}</span>
21
+ {% else %}
22
+ <a href="?p={{ i }}{% if cl.is_popup %}&_popup=1{% endif %}"
23
+ class="px-3 py-1.5 bg-surface-lowest border border-outline-variant text-on-surface-variant hover:bg-surface-low transition-all text-xs font-bold rounded-lg shadow-sm">
24
+ {{ i }}
25
+ </a>
26
+ {% endif %}
27
+ {% endfor %}
28
+ </nav>
29
+ {% endif %}
30
+ </div>
@@ -0,0 +1,28 @@
1
+ {% load i18n static %}
2
+ {% if cl.search_fields %}
3
+ <form id="changelist-search" method="get" class="flex items-center gap-2 w-full">
4
+ <div class="relative flex-1 min-w-0">
5
+ <span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant opacity-60 text-sm">search</span>
6
+ <input type="text" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar" autofocus
7
+ class="w-full pl-9 pr-3 py-1.5 rounded-lg border border-outline-variant bg-surface-low focus:ring-2 focus:ring-primary/10 focus:border-primary transition-all text-xs outline-none"
8
+ placeholder="{% translate 'Search...' %}"/>
9
+ </div>
10
+
11
+ <button type="submit" class="shrink-0 px-4 py-1.5 bg-primary text-white text-xs font-bold rounded-lg hover:bg-primary/90 transition-all shadow-sm">
12
+ {% translate 'Search' %}
13
+ </button>
14
+
15
+ {% if show_result_count %}
16
+ <span class="text-xs text-on-surface-variant font-medium shrink-0 hidden sm:inline-block">
17
+ {% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %}
18
+ (<a href="?{% if cl.is_popup %}_popup=1{% endif %}" class="text-primary hover:underline">{% blocktranslate with total_count=cl.show_full_result_count %}{{ total_count }} total{% endblocktranslate %}</a>)
19
+ </span>
20
+ {% endif %}
21
+
22
+ {% for pair in cl.params.items %}
23
+ {% if pair.0 != search_var %}
24
+ <input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}"/>
25
+ {% endif %}
26
+ {% endfor %}
27
+ </form>
28
+ {% endif %}
@@ -0,0 +1,48 @@
1
+ {% load i18n admin_urls %}
2
+ <div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 p-4 border border-outline-variant bg-surface-low/50 rounded-xl mt-6">
3
+ {% block submit-row %}
4
+ <!-- Delete Link (Destructive Action) -->
5
+ <div class="w-full sm:w-auto">
6
+ {% if show_delete_link and original %}
7
+ {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %}
8
+ <a href="{% add_preserved_filters delete_url %}"
9
+ class="flex items-center justify-center gap-1.5 w-full sm:w-auto px-4 py-2.5 sm:py-2 bg-rose-50 border border-rose-200 text-rose-600 rounded-lg hover:bg-rose-600 hover:text-white transition-all text-xs font-bold shadow-sm">
10
+ <span class="material-symbols-outlined text-sm">delete</span>
11
+ <span>{% translate "Delete" %}</span>
12
+ </a>
13
+ {% endif %}
14
+ </div>
15
+
16
+ <!-- Positive Actions -->
17
+ <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto">
18
+ {% if show_save_as_new %}
19
+ <button type="submit" name="_saveasnew"
20
+ class="w-full sm:w-auto text-center justify-center px-4 py-2.5 sm:py-2 bg-surface-lowest border border-outline-variant text-on-surface-variant hover:bg-surface-low rounded-lg text-xs font-bold transition-all shadow-sm">
21
+ {% translate 'Save as new' %}
22
+ </button>
23
+ {% endif %}
24
+
25
+ {% if show_save_and_add_another %}
26
+ <button type="submit" name="_addanother"
27
+ class="w-full sm:w-auto text-center justify-center px-4 py-2.5 sm:py-2 bg-surface-lowest border border-outline-variant text-on-surface-variant hover:bg-surface-low rounded-lg text-xs font-bold transition-all shadow-sm">
28
+ {% translate 'Save and add another' %}
29
+ </button>
30
+ {% endif %}
31
+
32
+ {% if show_save_and_continue %}
33
+ <button type="submit" name="_continue"
34
+ class="w-full sm:w-auto text-center justify-center px-4 py-2.5 sm:py-2 bg-surface-lowest border border-outline-variant text-on-surface-variant hover:bg-surface-low rounded-lg text-xs font-bold transition-all shadow-sm">
35
+ {% translate 'Save and continue editing' %}
36
+ </button>
37
+ {% endif %}
38
+
39
+ {% if show_save %}
40
+ <button type="submit" name="_save"
41
+ class="flex items-center justify-center gap-1.5 w-full sm:w-auto px-6 py-2.5 sm:py-2 bg-primary text-white rounded-lg text-xs font-bold hover:bg-primary/95 transition-all shadow-md active:scale-[0.98]">
42
+ <span class="material-symbols-outlined text-sm">check_circle</span>
43
+ <span>{% translate 'Save' %}</span>
44
+ </button>
45
+ {% endif %}
46
+ </div>
47
+ {% endblock %}
48
+ </div>
@@ -0,0 +1,280 @@
1
+ {% load i18n static adminflow_tags %}
2
+ <!DOCTYPE html>
3
+ <html lang="{{ LANGUAGE_CODE|default:'en-us' }}" dir="{{ LANGUAGE_BIDI|default:'ltr' }}" class="h-full">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{% translate "Security Verification" %} | {{ adminflow_title|default:"AdminFlow" }}</title>
8
+
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
12
+
13
+ <style>
14
+ body { font-family: 'Inter', sans-serif; }
15
+ </style>
16
+ </head>
17
+ <body class="h-full bg-surface text-on-surface antialiased flex flex-col justify-center py-12 sm:px-6 lg:px-8">
18
+
19
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
20
+ <div class="bg-surface-lowest border border-outline-variant/60 rounded-2xl p-8 shadow-xl space-y-6">
21
+
22
+ <!-- Header Icon & Title -->
23
+ <div class="text-center space-y-2">
24
+ <div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-primary/10 text-primary mb-2">
25
+ <span class="material-symbols-outlined text-[32px]">verified_user</span>
26
+ </div>
27
+ <h2 class="text-xl font-bold text-on-surface">{% translate "Two-Factor Verification" %}</h2>
28
+ <p class="text-xs text-on-surface-variant max-w-xs mx-auto">
29
+ Account: <strong class="text-on-surface">{{ user.get_username }}</strong>
30
+ </p>
31
+ </div>
32
+
33
+ <!-- 15-Minute Session Expiration Timer Banner -->
34
+ <div class="p-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-900 flex items-center justify-between">
35
+ <div class="flex items-center gap-2 font-medium">
36
+ <span class="material-symbols-outlined text-[18px] text-amber-600">timer</span>
37
+ <span>Session Expires In:</span>
38
+ </div>
39
+ <div id="session-timer" class="font-mono font-bold text-amber-700 bg-amber-200/60 px-2 py-0.5 rounded text-xs">
40
+ 15:00
41
+ </div>
42
+ </div>
43
+
44
+ <!-- 2FA Method Selector Dropdown -->
45
+ <div class="space-y-1.5 mb-4">
46
+ <label class="text-[11px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider block" for="security-method-select">
47
+ {% translate "Select Verification Method" %}
48
+ </label>
49
+ <div class="relative">
50
+ <select id="security-method-select" onchange="switch2FAOption(this.value)"
51
+ class="w-full h-11 px-4 pr-10 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-xs font-bold appearance-none cursor-pointer focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all outline-none shadow-sm">
52
+ {% if has_email_device %}
53
+ <option value="email">📧 Email Verification Code ({{ sent_email|default:user.email }})</option>
54
+ {% endif %}
55
+ {% if has_totp_device %}
56
+ <option value="totp">📱 Authenticator App (Google Authenticator / 1Password)</option>
57
+ {% endif %}
58
+ {% if has_static_device %}
59
+ <option value="static">🔑 Emergency Recovery Backup Code (10 Recovery Codes)</option>
60
+ {% endif %}
61
+ </select>
62
+ <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-slate-400">
63
+ <span class="material-symbols-outlined text-[20px]">unfold_more</span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Sent Email Info Banner -->
69
+ <div id="email-info-banner" class="p-3.5 bg-blue-50 border border-blue-200 rounded-xl text-xs text-blue-900 flex items-start gap-3" style="{% if not sent_email %}display: none;{% endif %}">
70
+ <span class="material-symbols-outlined text-[20px] text-blue-600 shrink-0">mark_email_read</span>
71
+ <div>
72
+ <p class="font-bold">Check your email inbox</p>
73
+ <p class="text-[11px] text-blue-700 mt-0.5">We sent a 6-digit verification code to <strong>{{ sent_email|default:user.email }}</strong>.</p>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- 2FA Code Submit Form -->
78
+ <form id="otp-form" method="POST" action="{% url 'adminflow_verify_2fa' %}" class="space-y-5">
79
+ {% csrf_token %}
80
+
81
+ <div class="space-y-1.5">
82
+ <label id="otp-label" class="text-[11px] font-bold text-on-surface-variant uppercase tracking-wider block" for="id_otp_token">
83
+ {% translate "VERIFICATION CODE (EMAIL)" %}
84
+ </label>
85
+ <div class="relative">
86
+ <input type="text" name="otp_token" id="id_otp_token" required autofocus autocomplete="off"
87
+ class="w-full h-12 px-4 text-center rounded-xl border border-outline-variant bg-surface-lowest text-on-surface text-base font-bold font-mono tracking-[4px] focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all outline-none"
88
+ placeholder="123456"/>
89
+ </div>
90
+ <p id="otp-helper-text" class="text-[11px] text-on-surface-variant text-center mt-1">
91
+ Enter the 6-digit code sent to your email inbox.
92
+ </p>
93
+ </div>
94
+
95
+ <button type="submit" id="btn-verify" style="background-color: #2563eb; color: #ffffff;" class="w-full h-12 bg-blue-600 text-white rounded-xl text-sm font-bold hover:bg-blue-700 active:scale-[0.99] transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2 cursor-pointer">
96
+ <span class="material-symbols-outlined text-[20px]">lock_open</span>
97
+ <span>{% translate "Verify & Log In" %}</span>
98
+ </button>
99
+ </form>
100
+
101
+ <!-- Action Links: Resend Email or Cancel -->
102
+ <div class="pt-4 border-t border-slate-200 flex items-center justify-between text-xs">
103
+ <div id="resend-form-container" style="{% if not sent_email %}display: none;{% endif %}">
104
+ <form id="resend-form" method="POST" action="{% url 'adminflow_verify_2fa' %}">
105
+ {% csrf_token %}
106
+ <input type="hidden" name="resend_email" value="1"/>
107
+ <button type="submit" id="btn-resend" class="font-bold text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1 cursor-pointer disabled:text-slate-400 disabled:no-underline disabled:cursor-not-allowed disabled:hover:text-slate-400 transition-colors">
108
+ <span class="material-symbols-outlined text-[16px]" id="resend-icon">refresh</span>
109
+ <span id="resend-text">Resend Email Code</span>
110
+ </button>
111
+ </form>
112
+ </div>
113
+
114
+ <a href="/admin/login/" class="font-semibold text-slate-500 hover:text-slate-800 ml-auto">
115
+ ← Back to Login
116
+ </a>
117
+ </div>
118
+
119
+ </div>
120
+ </div>
121
+
122
+ <!-- 15-Minute Countdown & Page Refresh Alert JS Logic -->
123
+ <script>
124
+ (function() {
125
+ let isSubmitting = false;
126
+
127
+ // Forms flag form submission to bypass refresh alert and show loading state
128
+ document.querySelectorAll('form').forEach(form => {
129
+ form.addEventListener('submit', function() {
130
+ isSubmitting = true;
131
+ });
132
+ });
133
+
134
+ const otpForm = document.getElementById('otp-form');
135
+ const verifyBtn = document.getElementById('btn-verify');
136
+ const resendForm = document.getElementById('resend-form');
137
+ const btnResend = document.getElementById('btn-resend');
138
+ const resendText = document.getElementById('resend-text');
139
+ const resendIcon = document.getElementById('resend-icon');
140
+
141
+ if (otpForm && verifyBtn) {
142
+ otpForm.addEventListener('submit', function() {
143
+ verifyBtn.disabled = true;
144
+ verifyBtn.classList.add('opacity-75', 'cursor-not-allowed', 'pointer-events-none');
145
+ verifyBtn.innerHTML = `
146
+ <svg class="animate-spin h-5 w-5 text-white inline-block shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
147
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
148
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
149
+ </svg>
150
+ <span>Verifying Code...</span>
151
+ `;
152
+ });
153
+ }
154
+
155
+ if (resendForm && btnResend) {
156
+ resendForm.addEventListener('submit', function() {
157
+ btnResend.disabled = true;
158
+ btnResend.classList.add('opacity-75', 'cursor-not-allowed', 'pointer-events-none');
159
+ btnResend.innerHTML = `
160
+ <svg class="animate-spin h-4 w-4 text-blue-600 inline-block shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
161
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
162
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
163
+ </svg>
164
+ <span>Sending Email Code...</span>
165
+ `;
166
+ });
167
+ }
168
+
169
+ // Detect if page was refreshed/reloaded
170
+ const navEntries = performance.getEntriesByType && performance.getEntriesByType('navigation');
171
+ const isReload = navEntries && navEntries.length > 0 ? navEntries[0].type === 'reload' : (performance.navigation && performance.navigation.type === 1);
172
+
173
+ if (isReload) {
174
+ // If page reloaded, redirect to login page directly
175
+ window.location.href = '/admin/login/?reason=refreshed';
176
+ return;
177
+ }
178
+
179
+ // 2FA Method Switcher Function
180
+ window.switch2FAOption = function(option) {
181
+ const methodSelect = document.getElementById('security-method-select');
182
+ if (methodSelect && methodSelect.value !== option) {
183
+ methodSelect.value = option;
184
+ }
185
+
186
+ const emailBanner = document.getElementById('email-info-banner');
187
+ const resendContainer = document.getElementById('resend-form-container');
188
+
189
+ const inputLabel = document.getElementById('otp-label');
190
+ const inputField = document.getElementById('id_otp_token');
191
+ const helperText = document.getElementById('otp-helper-text');
192
+
193
+ if (option === 'email') {
194
+ if (emailBanner) emailBanner.style.display = 'flex';
195
+ if (resendContainer) resendContainer.style.display = 'block';
196
+ if (inputLabel) inputLabel.textContent = 'VERIFICATION CODE (EMAIL)';
197
+ if (inputField) { inputField.placeholder = '123456'; inputField.focus(); }
198
+ if (helperText) helperText.innerHTML = 'Enter the 6-digit code sent to your email inbox.';
199
+ } else if (option === 'totp') {
200
+ if (emailBanner) emailBanner.style.display = 'none';
201
+ if (resendContainer) resendContainer.style.display = 'none';
202
+ if (inputLabel) inputLabel.textContent = 'AUTHENTICATOR CODE (TOTP)';
203
+ if (inputField) { inputField.placeholder = '123456'; inputField.focus(); }
204
+ if (helperText) helperText.innerHTML = 'Enter the 6-digit code from <strong>Google Authenticator</strong> or <strong>1Password</strong>.';
205
+ } else if (option === 'static') {
206
+ if (emailBanner) emailBanner.style.display = 'none';
207
+ if (resendContainer) resendContainer.style.display = 'none';
208
+ if (inputLabel) inputLabel.textContent = 'EMERGENCY RECOVERY CODE';
209
+ if (inputField) { inputField.placeholder = 'Recovery Code'; inputField.focus(); }
210
+ if (helperText) helperText.innerHTML = 'Enter one of your 10 single-use <strong>Emergency Recovery Backup Codes</strong>.';
211
+ }
212
+ };
213
+
214
+ // Auto-select initial tab based on device availability
215
+ const initialOption = ("{{ has_email_device }}" === "True" && "{{ sent_email }}") ? "email" : ("{{ has_totp_device }}" === "True" ? "totp" : ("{{ has_static_device }}" === "True" ? "static" : ("{{ has_email_device }}" === "True" ? "email" : "static")));
216
+ switch2FAOption(initialOption);
217
+ window.addEventListener('beforeunload', function(e) {
218
+ if (!isSubmitting) {
219
+ const msg = "Are you sure you want to refresh? Refreshing will cancel your 2FA verification session and return you to the login page.";
220
+ e.preventDefault();
221
+ e.returnValue = msg;
222
+ return msg;
223
+ }
224
+ });
225
+
226
+ // 60-Second Resend Email Code Cooldown Timer
227
+ let resendSeconds = parseInt("{{ resend_cooldown_remaining|default:60 }}", 10);
228
+
229
+ function updateResendTimer() {
230
+ if (resendSeconds > 0) {
231
+ if (btnResend) {
232
+ btnResend.disabled = true;
233
+ btnResend.classList.add('opacity-50', 'pointer-events-none');
234
+ }
235
+ if (resendText) resendText.textContent = `Resend Code in ${resendSeconds}s`;
236
+ if (resendIcon) resendIcon.classList.add('animate-spin');
237
+ resendSeconds--;
238
+ } else {
239
+ if (btnResend) {
240
+ btnResend.disabled = false;
241
+ btnResend.classList.remove('opacity-50', 'pointer-events-none');
242
+ }
243
+ if (resendText) resendText.textContent = 'Resend Email Code';
244
+ if (resendIcon) resendIcon.classList.remove('animate-spin');
245
+ }
246
+ }
247
+ updateResendTimer();
248
+ setInterval(updateResendTimer, 1000);
249
+
250
+ // 15-Minute Live Countdown Timer (900 seconds)
251
+ let remainingSeconds = parseInt("{{ remaining_seconds|default:900 }}", 10);
252
+ const timerEl = document.getElementById('session-timer');
253
+
254
+ function updateTimer() {
255
+ if (remainingSeconds <= 0) {
256
+ if (timerEl) timerEl.textContent = "00:00";
257
+ isSubmitting = true;
258
+ alert("Your 15-minute 2FA verification session has expired. Redirecting to login page.");
259
+ window.location.href = '/admin/login/?reason=expired';
260
+ return;
261
+ }
262
+
263
+ const mins = Math.floor(remainingSeconds / 60);
264
+ const secs = remainingSeconds % 60;
265
+ if (timerEl) {
266
+ timerEl.textContent = (mins < 10 ? "0" : "") + mins + ":" + (secs < 10 ? "0" : "") + secs;
267
+ }
268
+ remainingSeconds--;
269
+ }
270
+
271
+ updateTimer();
272
+ setInterval(updateTimer, 1000);
273
+ })();
274
+ </script>
275
+
276
+ <!-- Floating Toast Notifications -->
277
+ {% include "admin/includes/toast.html" %}
278
+
279
+ </body>
280
+ </html>