b4n1 0.0.2__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.
b4n1/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ """
2
+ b4n1-fast Python SDK — Write Python, Run Rust
3
+ Complete web framework with ORM, validation, middleware, admin, swagger, PWA,
4
+ queue, auth, email, config, API keys, and native database (b4n1-db).
5
+ """
6
+
7
+ from b4n1.app import App, Request, Router
8
+ from b4n1.models import Model, QuerySet
9
+ from b4n1.responses import JsonResponse
10
+ from b4n1.validation import ValidationError, validate_email, validate_password
11
+ from b4n1.admin import (
12
+ AdminSite, AdminConfig, register_admin_routes,
13
+ register_module, render_sidebar_html, get_modules, clear_modules,
14
+ inject_edit_toolbar, register_edit_api,
15
+ )
16
+ from b4n1.swagger import SwaggerGenerator, RouteInfo
17
+ from b4n1.pwa import PwaManifest, ServiceWorker, generate_pwa_html
18
+ from b4n1.templates import (
19
+ ReactiveTemplateEngine,
20
+ render_template,
21
+ render_string,
22
+ render_window,
23
+ render_window_str,
24
+ send_html,
25
+ send_redirect,
26
+ )
27
+ from b4n1.queue import B4N1Queue, Task, Worker, Scheduler, TaskBroker
28
+ from b4n1.queue_admin import QueueAdminPanel
29
+ from b4n1.auth import (
30
+ ROLES,
31
+ User,
32
+ AuthBackend,
33
+ hash_password,
34
+ verify_password,
35
+ init_role_system,
36
+ set_user_role,
37
+ is_admin,
38
+ is_superadmin,
39
+ create_session,
40
+ get_user_from_session,
41
+ get_session_cookie,
42
+ require_auth,
43
+ require_admin,
44
+ require_superadmin,
45
+ log_login,
46
+ get_client_ip,
47
+ create_superuser,
48
+ )
49
+
50
+ from b4n1.db import NativeBackend, SQLiteAdapter, get_db, _QueryResult
51
+ from b4n1.config import Settings, settings
52
+ from b4n1.mail import (
53
+ send_email,
54
+ generate_verification_email,
55
+ generate_reset_email,
56
+ )
57
+ from b4n1.api_keys import (
58
+ generate_key,
59
+ list_keys,
60
+ create_key,
61
+ revoke_key,
62
+ update_fingerprint,
63
+ lookup_key,
64
+ MAX_KEYS_PER_USER,
65
+ )
66
+ from b4n1.image_gen import generate_image, register_image_api
67
+ from b4n1.rate_limiter import SlidingWindowRateLimiter, RateLimitMiddleware
68
+ from b4n1.middleware import (
69
+ native_dumps,
70
+ native_loads,
71
+ DjangoNativeMiddleware,
72
+ FlaskNativeMiddleware,
73
+ FastAPINativeMiddleware,
74
+ )
75
+
76
+ __all__ = [
77
+ "App",
78
+ "Request",
79
+ "Router",
80
+ "Model",
81
+ "QuerySet",
82
+ "JsonResponse",
83
+ "ValidationError",
84
+ "validate_email",
85
+ "validate_password",
86
+ "AdminSite",
87
+ "AdminConfig",
88
+ "register_module",
89
+ "render_sidebar_html",
90
+ "get_modules",
91
+ "clear_modules",
92
+ "inject_edit_toolbar",
93
+ "register_edit_api",
94
+ "SwaggerGenerator",
95
+ "RouteInfo",
96
+ "PwaManifest",
97
+ "ServiceWorker",
98
+ "generate_pwa_html",
99
+ "ReactiveTemplateEngine",
100
+ "B4N1Queue",
101
+ "Task",
102
+ "Worker",
103
+ "Scheduler",
104
+ "TaskBroker",
105
+ "QueueAdminPanel",
106
+ "AuthBackend",
107
+ "User",
108
+ "NativeBackend",
109
+ "SQLiteAdapter",
110
+ "get_db",
111
+ "_QueryResult",
112
+ "Settings",
113
+ "settings",
114
+ "send_email",
115
+ "generate_verification_email",
116
+ "generate_reset_email",
117
+ "generate_key",
118
+ "list_keys",
119
+ "create_key",
120
+ "revoke_key",
121
+ "update_fingerprint",
122
+ "lookup_key",
123
+ "MAX_KEYS_PER_USER",
124
+ "generate_image",
125
+ "register_image_api",
126
+ "SlidingWindowRateLimiter",
127
+ "RateLimitMiddleware",
128
+ "native_dumps",
129
+ "native_loads",
130
+ "DjangoNativeMiddleware",
131
+ "FlaskNativeMiddleware",
132
+ "FastAPINativeMiddleware",
133
+ ]
b4n1/admin/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from b4n1.admin.site import AdminConfig, AdminSite
2
+ from b4n1.admin.views import register_admin_routes
3
+ from b4n1.admin.menu import register_module, render_sidebar_html, get_modules, clear_modules
4
+ from b4n1.admin.inline_edit import inject_edit_toolbar, register_edit_api
5
+
6
+ __all__ = [
7
+ "AdminConfig", "AdminSite",
8
+ "register_admin_routes",
9
+ "register_module", "render_sidebar_html", "get_modules", "clear_modules",
10
+ "inject_edit_toolbar", "register_edit_api",
11
+ ]
@@ -0,0 +1,82 @@
1
+ """Inline Visual Editor — admin edit-in-place on public pages.
2
+
3
+ Usage:
4
+ 1. Add `data-edit` + `data-field` + `data-type` to any DOM element
5
+ 2. Call `inject_edit_toolbar(html, user)` in your page render
6
+ 3. Register save handlers with `register_edit_api(app, save_callback)`
7
+ """
8
+
9
+ INLINE_CSS = """
10
+ .editable{position:relative;cursor:pointer;transition:outline .2s}
11
+ .editable:hover{outline:2px dashed #16a34a;outline-offset:2px}
12
+ .editable[data-type=image]:hover::after{content:'\\270F\\FE0F';position:absolute;top:4px;right:4px;background:#16a34a;color:#fff;border-radius:4px;padding:2px 6px;font-size:12px;z-index:10;line-height:1.4}
13
+ .editable[data-type=text]:hover::after{content:'\\270F\\FE0F';position:absolute;top:-8px;right:-8px;background:#16a34a;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;z-index:10}
14
+ """
15
+
16
+ INLINE_JS = """
17
+ (function(){
18
+ if(window.__b4n1_inline_editor)return;window.__b4n1_inline_editor=true;
19
+ var s=document.createElement('style');
20
+ s.textContent=INLINE_CSS_PLACEHOLDER;
21
+ document.head.appendChild(s);
22
+ document.querySelectorAll('[data-edit]').forEach(function(el){
23
+ el.classList.add('editable');
24
+ el.addEventListener('click',function(e){
25
+ e.preventDefault();e.stopPropagation();
26
+ var type=this.dataset.type||'text';
27
+ var field=this.dataset.field||'';
28
+ var val=type==='image'?this.src:this.textContent;
29
+ var p=prompt('Editar '+field+':',val);
30
+ if(p&&p!==val){
31
+ if(type==='image')this.src=p;
32
+ else this.textContent=p;
33
+ var fd=new FormData();
34
+ fd.append('field',field);
35
+ fd.append('value',p);
36
+ fetch('/api/admin/edit',{method:'POST',body:fd});
37
+ }
38
+ });
39
+ });
40
+ })();
41
+ """
42
+
43
+
44
+ def inject_edit_toolbar(html: str, user: dict | None) -> str:
45
+ if not user:
46
+ return html
47
+ role = user.get("role", "")
48
+ if role not in ("admin", "superadmin"):
49
+ return html
50
+ script = INLINE_JS.replace("INLINE_CSS_PLACEHOLDER", INLINE_CSS.replace("'", "\\'").replace("\n", " "))
51
+ return html.replace("</body>", f"<script>{script}</script>\n</body>")
52
+
53
+
54
+ def register_edit_api(app, save_callback):
55
+ """Register the /api/admin/edit endpoint.
56
+
57
+ save_callback(field: str, value: str) -> None is called on each edit.
58
+ """
59
+ import json
60
+
61
+ @app.post("/api/admin/edit")
62
+ def _inline_edit_handler(handler, body, **kw):
63
+ try:
64
+ if body and body.startswith("{"):
65
+ data = json.loads(body)
66
+ else:
67
+ import urllib.parse
68
+ data = urllib.parse.parse_qs(body)
69
+ data = {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in data.items()}
70
+ field = data.get("field", "")
71
+ value = data.get("value", "")
72
+ if field:
73
+ save_callback(field, value)
74
+ handler.send_response(200)
75
+ handler.send_header("Content-Type", "application/json")
76
+ handler.end_headers()
77
+ handler.wfile.write(b'{"ok":true}')
78
+ except Exception as e:
79
+ handler.send_response(500)
80
+ handler.end_headers()
81
+ handler.wfile.write(str(e).encode())
82
+ return None
b4n1/admin/menu.py ADDED
@@ -0,0 +1,34 @@
1
+ """Admin Super Menu — Drupal-like module system.
2
+
3
+ Apps register their modules with icon, route, and order.
4
+ """
5
+
6
+ _MODULES: list[dict] = []
7
+
8
+
9
+ def register_module(name: str, icon: str, route: str, order: int = 50, badge: str = ""):
10
+ """Register a module in the admin sidebar menu."""
11
+ _MODULES.append(dict(name=name, icon=icon, route=route, order=order, badge=badge))
12
+ _MODULES.sort(key=lambda m: m["order"])
13
+
14
+
15
+ def render_sidebar_html(current_route: str = "/admin") -> str:
16
+ items = ""
17
+ for m in _MODULES:
18
+ active = "active" if m["route"] == current_route else ""
19
+ badge_html = f'<span class="menu-badge">{m["badge"]}</span>' if m.get("badge") else ""
20
+ items += (
21
+ f'<a href="{m["route"]}" class="{active}">'
22
+ f'<span class="menu-icon">{m["icon"]}</span>'
23
+ f'<span class="menu-label">{m["name"]}</span>'
24
+ f'{badge_html}</a>'
25
+ )
26
+ return items
27
+
28
+
29
+ def get_modules() -> list[dict]:
30
+ return list(_MODULES)
31
+
32
+
33
+ def clear_modules():
34
+ _MODULES.clear()
b4n1/admin/site.py ADDED
@@ -0,0 +1,40 @@
1
+ class AdminConfig:
2
+ def __init__(self, model_name):
3
+ self.model_name = model_name
4
+ self.list_display = []
5
+ self.search_fields = []
6
+ self.list_filter = []
7
+ self.ordering = None
8
+ self.list_per_page = 100
9
+
10
+ def with_list_display(self, fields):
11
+ self.list_display = list(fields)
12
+ return self
13
+
14
+ def with_search_fields(self, fields):
15
+ self.search_fields = list(fields)
16
+ return self
17
+
18
+ def with_list_filter(self, fields):
19
+ self.list_filter = list(fields)
20
+ return self
21
+
22
+ def with_ordering(self, field):
23
+ self.ordering = field
24
+ return self
25
+
26
+
27
+ class AdminSite:
28
+ def __init__(self, title="B4N1-FAST Admin"):
29
+ self.title = title
30
+ self.configs = {}
31
+
32
+ def register(self, config):
33
+ self.configs[config.model_name] = config
34
+ return self
35
+
36
+ def generate_dashboard_url(self):
37
+ return "/admin/"
38
+
39
+ def generate_list_url(self, model_name):
40
+ return f"/admin/{model_name}/"
b4n1/admin/views.py ADDED
@@ -0,0 +1,280 @@
1
+ """Admin CRUD views: login, dashboard, user CRUD, API keys, telemetry, inline edit API."""
2
+
3
+ import secrets, urllib.parse, json, time
4
+ from b4n1.auth import (
5
+ ROLES, hash_password, verify_password, require_admin, require_superadmin,
6
+ create_session, get_session_cookie, get_user_from_session, log_login,
7
+ get_client_ip,
8
+ )
9
+ from b4n1.templates import render_window, send_html, send_redirect
10
+ from b4n1.admin.menu import render_sidebar_html, register_module
11
+
12
+
13
+ def _role_opt(selected_role):
14
+ return "".join(
15
+ f'<option value="{r}" {"selected" if r == selected_role else ""}>{r}</option>'
16
+ for r in ROLES
17
+ )
18
+
19
+
20
+ def _user_rows(users):
21
+ rows = ""
22
+ for u in users:
23
+ role_cls = {
24
+ "superadmin": "bg-purple-600/30 text-purple-400",
25
+ "admin": "bg-red-600/30 text-red-400",
26
+ "staff": "bg-blue-600/30 text-blue-400",
27
+ "user": "bg-green-600/20 text-green-400",
28
+ }.get(u.get("role", "user"), "bg-gray-600/20 text-gray-400")
29
+ status_text = "Active" if u.get("is_active") else "Inactive"
30
+ st_cls = "text-green-400" if u.get("is_active") else "text-red-400"
31
+ uid = u["id"]
32
+ uname = u["username"]
33
+ email = u.get("email") or "-"
34
+ role_val = u.get("role") or "user"
35
+ rows += ("<tr class='border-b border-white/5 hover:bg-white/5'>"
36
+ + f"<td class='py-3 px-4 text-sm'>{uid}</td>"
37
+ + f"<td class='py-3 px-4 text-sm font-medium'>{uname}</td>"
38
+ + f"<td class='py-3 px-4 text-sm text-gray-400'>{email}</td>"
39
+ + f"<td class='py-3 px-4'><span class='text-[10px] px-2 py-0.5 rounded-full border {role_cls}'>{role_val}</span></td>"
40
+ + f"<td class='py-3 px-4 text-sm {st_cls}'>{status_text}</td>"
41
+ + f"<td class='py-3 px-4 text-right'>"
42
+ + f"<button hx-get='/admin/users/edit/{uid}' hx-target='#desktop' hx-swap='beforeend' class='text-blue-400 hover:text-blue-300 text-xs font-medium cursor-pointer'>Edit</button>"
43
+ + f"<button hx-delete='/admin/users/{uid}' hx-confirm='Delete {uname}?' hx-target='closest tr' hx-swap='delete' class='text-red-400 hover:text-red-300 text-xs font-medium ml-2'>Delete</button>"
44
+ + "</td></tr>")
45
+ return rows
46
+
47
+
48
+ def _api_key_rows(db):
49
+ rows = db.execute(
50
+ "SELECT ak.id, ak.user_id, ak.key, ak.name, ak.is_active, "
51
+ "ak.created_at, u.username "
52
+ "FROM api_keys ak JOIN users u ON ak.user_id = u.id ORDER BY ak.created_at DESC"
53
+ ).fetchall()
54
+ html = ""
55
+ for r in rows:
56
+ sc = "text-green-400" if r["is_active"] else "text-red-400"
57
+ ks = r["key"][:12] + "..." if len(r["key"]) > 15 else r["key"]
58
+ html += (
59
+ f"<tr class='border-b border-white/5 hover:bg-white/5'>"
60
+ f"<td class='py-2 px-3 text-xs text-gray-400'>{r['id']}</td>"
61
+ f"<td class='py-2 px-3 text-xs'>{r['username']}</td>"
62
+ f"<td class='py-2 px-3 text-xs font-mono text-gray-300'>{ks}</td>"
63
+ f"<td class='py-2 px-3 text-xs text-gray-400'>{r.get('name') or '-'}</td>"
64
+ f"<td class='py-2 px-3 text-xs {sc}'>{'Active' if r['is_active'] else 'Inactive'}</td></tr>"
65
+ )
66
+ return html, len(rows)
67
+
68
+
69
+ def _r(ctx=None):
70
+ return str(secrets.randbits(32))
71
+
72
+
73
+ _LOGIN_HTML = """<!DOCTYPE html>
74
+ <html lang="es"><head>
75
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
76
+ <title>Ingresar — B4N1-FAST Admin</title>
77
+ <script src="https://cdn.tailwindcss.com"></script>
78
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
79
+ <style>
80
+ *{margin:0;padding:0;box-sizing:border-box}
81
+ body{font-family:'Inter',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center}
82
+ .login-card{background:#1e293b;border-radius:16px;padding:40px;width:400px;border:1px solid #334155;box-shadow:0 25px 50px -12px rgba(0,0,0,.5)}
83
+ .login-card h1{font-size:1.5rem;font-weight:700;margin-bottom:4px}
84
+ .login-card p{color:#64748b;font-size:.9rem;margin-bottom:24px}
85
+ .form-group{margin-bottom:16px}
86
+ .form-group label{display:block;font-size:.8rem;font-weight:600;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
87
+ .form-group input{width:100%;padding:10px 14px;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;font-size:.9rem;outline:none;transition:border .2s}
88
+ .form-group input:focus{border-color:#16a34a}
89
+ .btn-login{width:100%;padding:12px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:.95rem;font-weight:600;cursor:pointer;transition:background .2s}
90
+ .btn-login:hover{background:#15803d}
91
+ .error{background:#7f1d1d;color:#fca5a5;padding:10px 14px;border-radius:8px;font-size:.85rem;margin-bottom:16px;border:1px solid #991b1b}
92
+ .footer{text-align:center;margin-top:20px;font-size:.8rem;color:#475569}
93
+ </style></head><body>
94
+ <div class="login-card">
95
+ <h1>🔐 Ingresar</h1>
96
+ <p>Administración B4N1-FAST</p>
97
+ {ERROR_BLOCK}
98
+ <form method="post" action="{LOGIN_ACTION}">
99
+ <div class="form-group"><label>Usuario</label><input type="text" name="username" required autofocus></div>
100
+ <div class="form-group"><label>Contraseña</label><input type="password" name="password" required></div>
101
+ <button type="submit" class="btn-login">Ingresar</button>
102
+ </form>
103
+ <div class="footer">B4N1-FAST v1.0</div>
104
+ </div></body></html>"""
105
+
106
+
107
+ def _login_page_html(error="", login_route="/login"):
108
+ err = f'<div class="error">{error}</div>' if error else ""
109
+ return _LOGIN_HTML.replace("{ERROR_BLOCK}", err).replace("{LOGIN_ACTION}", login_route)
110
+
111
+
112
+ def register_admin_routes(app, login_route="/login", logout_route="/logout"):
113
+ register_module("Dashboard", "📊", "/admin", order=10)
114
+ register_module("Usuarios", "👥", "/admin", order=20)
115
+ register_module("API Keys", "🔑", "/api-keys", order=80)
116
+
117
+ @app.get(login_route)
118
+ def login_get(h, body):
119
+ return send_html(h, _login_page_html(login_route=login_route))
120
+
121
+ @app.post(login_route)
122
+ def login_post(h, body):
123
+ data = urllib.parse.parse_qs(body)
124
+ uname = data.get("username", [""])[0]
125
+ pw = data.get("password", [""])[0]
126
+ db = app._db
127
+ user = db.execute(
128
+ "SELECT * FROM users WHERE username=? AND is_active=1", (uname,)
129
+ ).fetchone()
130
+ if not user or not verify_password(pw, user["password_hash"]):
131
+ log_login(0, uname, get_client_ip(h), h.headers.get("User-Agent", ""), success=False)
132
+ return send_html(h, _login_page_html("Usuario o contraseña incorrectos", login_route))
133
+ log_login(user["id"], uname, get_client_ip(h), h.headers.get("User-Agent", ""), success=True)
134
+ key = create_session(user["id"], get_client_ip(h), h.headers.get("User-Agent", ""))
135
+ h.send_response(302)
136
+ h.send_header("Set-Cookie", f"sessionid={key}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400")
137
+ h.send_header("Location", "/admin")
138
+ h.send_header("Content-Length", "0")
139
+ h.end_headers()
140
+ return None
141
+
142
+ @app.get(logout_route)
143
+ def logout_get(h, body):
144
+ cookie = get_session_cookie(h.headers)
145
+ if cookie:
146
+ db = app._db
147
+ db.execute("DELETE FROM sessions WHERE session_key=?", (cookie,))
148
+ h.send_response(302)
149
+ h.send_header("Set-Cookie", "sessionid=; Path=/; HttpOnly; Max-Age=0")
150
+ h.send_header("Location", login_route)
151
+ h.send_header("Content-Length", "0")
152
+ h.end_headers()
153
+ return None
154
+
155
+ @app.get("/admin")
156
+ @require_admin
157
+ def admin_dashboard(h, body, user):
158
+ db = app._db
159
+ t = db.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
160
+ a = db.execute("SELECT COUNT(*) as c FROM users WHERE is_active = 1").fetchone()["c"]
161
+ ac = db.execute("SELECT COUNT(*) as c FROM users WHERE role IN ('superadmin','admin')").fetchone()["c"]
162
+ sc = db.execute("SELECT COUNT(*) as c FROM users WHERE role = 'staff'").fetchone()["c"]
163
+ l24 = db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at > datetime('now','-24 hours') AND success=1").fetchone()["c"]
164
+ f24 = db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at > datetime('now','-24 hours') AND success=0").fetchone()["c"]
165
+ r24 = db.execute("SELECT COUNT(*) as c FROM users WHERE created_at > datetime('now','-24 hours')").fetchone()["c"]
166
+ recent = db.execute("SELECT username,ip_address,success,created_at FROM login_logs ORDER BY created_at DESC LIMIT 20").fetchall()
167
+ users = db.execute("SELECT id,username,email,role,is_active FROM users ORDER BY created_at DESC").fetchall()
168
+ rh = "".join(
169
+ f"<tr class='text-[10px] border-b border-white/5'>"
170
+ f"<td class='py-1 px-2'>{'🟢' if r['success'] else '🔴'}</td>"
171
+ f"<td class='py-1 px-2'>{r.get('username') or '-'}</td>"
172
+ f"<td class='py-1 px-2 text-gray-400'>{r.get('ip_address') or '-'}</td>"
173
+ f"<td class='py-1 px-2 text-gray-500'>{r['created_at']}</td></tr>"
174
+ for r in recent
175
+ )
176
+ sidebar = render_sidebar_html("/admin")
177
+ ctx = dict(total_users=t, active_users=a, admin_users=ac, staff_users=sc,
178
+ logins_24h=l24, failed_24h=f24, registrations_24h=r24,
179
+ recent_logins=rh, users_rows=_user_rows(users),
180
+ sidebar=sidebar, random=_r(), user=user)
181
+ return render_window(h, "admin", "Admin Dashboard", "fa-shield-halved",
182
+ "admin/dashboard.html", ctx, "900px", "600px")
183
+
184
+ @app.get("/admin/users/create")
185
+ @require_superadmin
186
+ def admin_create_user_get(h, body, user):
187
+ return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
188
+ "admin/create_user.html", dict(random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
189
+
190
+ @app.post("/admin/users/create")
191
+ @require_superadmin
192
+ def admin_create_user_post(h, body, user):
193
+ data = urllib.parse.parse_qs(body)
194
+ uname = data.get("username", [""])[0]
195
+ email = data.get("email", [""])[0]
196
+ pw = data.get("password", [""])[0]
197
+ role = data.get("role", ["user"])[0]
198
+ if not uname or not email or not pw:
199
+ return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
200
+ "admin/create_user.html", dict(error="All fields required", random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
201
+ db = app._db
202
+ if db.execute("SELECT id FROM users WHERE username=? OR email=?", (uname, email)).fetchone():
203
+ return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
204
+ "admin/create_user.html", dict(error="Username or email exists", random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
205
+ role = role if role in ROLES else "user"
206
+ db.execute(
207
+ "INSERT INTO users (username,email,password_hash,is_active,is_staff,is_superuser,role) VALUES (?,?,?,1,?,?,?)",
208
+ (uname, email, hash_password(pw), 1 if role in ("superadmin", "admin", "staff") else 0,
209
+ 1 if role in ("superadmin", "admin") else 0, role)
210
+ )
211
+ new_id = db.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone()["id"]
212
+ db.execute("INSERT INTO api_keys (user_id,key,name) VALUES (?,?,'Default Key')",
213
+ (new_id, f"b4n1_sk_{secrets.token_urlsafe(24)}"))
214
+ return send_redirect(h, "/admin")
215
+
216
+ @app.get("/admin/users/edit/{user_id}")
217
+ @require_admin
218
+ def admin_edit_user_get(h, body, user, user_id=None):
219
+ db = app._db
220
+ eu = db.execute("SELECT id,username,email,role,is_active FROM users WHERE id=?", (user_id,)).fetchone()
221
+ if not eu:
222
+ h.send_response(404); h.end_headers(); h.wfile.write(b"User not found"); return None
223
+ ctx = dict(user_id=eu["id"], username=eu["username"], email=eu.get("email") or "",
224
+ role_options=_role_opt(eu.get("role") or "user"), is_active=eu["is_active"],
225
+ random=_r(), user=user, sidebar=render_sidebar_html())
226
+ return render_window(h, "admin-edit-user", "Edit User", "fa-user-edit",
227
+ "admin/edit_user.html", ctx, "450px", "450px")
228
+
229
+ @app.post("/admin/users/edit/{user_id}")
230
+ @require_admin
231
+ def admin_edit_user_post(h, body, user, user_id=None):
232
+ data = urllib.parse.parse_qs(body)
233
+ uname = data.get("username", [""])[0]
234
+ email = data.get("email", [""])[0]
235
+ role = data.get("role", [""])[0]
236
+ active = 1 if data.get("is_active", [""])[0] == "1" else 0
237
+ npw = data.get("new_password", [""])[0]
238
+ db = app._db
239
+ if role in ROLES:
240
+ db.execute(
241
+ "UPDATE users SET username=?,email=?,role=?,is_active=?,is_superuser=?,is_staff=? WHERE id=?",
242
+ (uname, email, role, active, 1 if role in ("superadmin", "admin") else 0,
243
+ 1 if role in ("superadmin", "admin", "staff") else 0, user_id)
244
+ )
245
+ else:
246
+ db.execute("UPDATE users SET username=?,email=?,is_active=? WHERE id=?", (uname, email, active, user_id))
247
+ if npw:
248
+ db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(npw), user_id))
249
+ return send_redirect(h, "/admin")
250
+
251
+ @app.delete("/admin/users/{user_id}")
252
+ @require_admin
253
+ def admin_delete_user(h, body, user, user_id=None):
254
+ if str(user["id"]) == str(user_id):
255
+ return {"status": "error", "message": "Cannot delete yourself"}
256
+ db = app._db
257
+ for tbl in ("sessions", "api_keys", "email_tokens", "password_resets"):
258
+ db.execute(f"DELETE FROM {tbl} WHERE user_id=?", (user_id,))
259
+ db.execute("DELETE FROM users WHERE id=?", (user_id,))
260
+ return {"status": "deleted"}
261
+
262
+ @app.get("/api-keys")
263
+ @require_admin
264
+ def admin_api_keys(h, body, user):
265
+ kh, total = _api_key_rows(app._db)
266
+ return render_window(h, "api-keys", "API Keys", "fa-key",
267
+ "admin/api_keys.html", dict(rows=kh, total=total, random=_r(), user=user, sidebar=render_sidebar_html()), "800px", "450px")
268
+
269
+ @app.get("/api/admin/telemetry")
270
+ @require_admin
271
+ def admin_telemetry(h, body, user):
272
+ db = app._db
273
+ return dict(
274
+ total_users=db.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"],
275
+ active_users=db.execute("SELECT COUNT(*) as c FROM users WHERE is_active=1").fetchone()["c"],
276
+ logins_24h=db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at>datetime('now','-24 hours') AND success=1").fetchone()["c"],
277
+ failed_logins_24h=db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at>datetime('now','-24 hours') AND success=0").fetchone()["c"],
278
+ registrations_7d=db.execute("SELECT COUNT(*) as c FROM users WHERE created_at>datetime('now','-7 days')").fetchone()["c"],
279
+ active_api_keys=db.execute("SELECT COUNT(*) as c FROM api_keys WHERE is_active=1").fetchone()["c"],
280
+ )