appmanager-server 0.2.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 (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,900 @@
1
+ from flask import (
2
+ Blueprint,
3
+ current_app,
4
+ flash,
5
+ jsonify,
6
+ redirect,
7
+ render_template,
8
+ request,
9
+ url_for,
10
+ )
11
+
12
+ from appmanager.admin.app_installer import (
13
+ cancel_staged_app,
14
+ finalize_staged_installation,
15
+ install_from_git,
16
+ install_from_zip,
17
+ sanitize_slug,
18
+ stage_git_repo,
19
+ stage_zip_file,
20
+ uninstall_app,
21
+ )
22
+ from appmanager.admin.members import (
23
+ DEFAULT_PER_PAGE,
24
+ PER_PAGE_CHOICES,
25
+ app_access_counts,
26
+ list_members,
27
+ member_permissions,
28
+ total_app_count,
29
+ )
30
+ from appmanager.auth.utils import admin_required, get_current_user
31
+ from appmanager.database import db
32
+ from appmanager.health import check_all_apps_health
33
+ from appmanager.models import AppHealthLog, InstalledApp, Role, User, UserAppPermission
34
+
35
+ admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
36
+
37
+
38
+ @admin_bp.route("/")
39
+ @admin_required
40
+ def dashboard():
41
+ """
42
+ Consolidated admin dashboard. Renders all admin sections (apps, users,
43
+ roles, flairs, permissions) as tabs on a single page.
44
+ """
45
+ from appmanager.extensions import DEFAULT_PRESET_FLAIRS, get_user_flair
46
+
47
+ user = get_current_user()
48
+ apps = InstalledApp.query.order_by(InstalledApp.installed_at.desc()).all()
49
+ users = User.query.order_by(User.created_at.desc()).all()
50
+ roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
51
+
52
+ # Get latest health log for each app
53
+ health_map = {}
54
+ health_history = {}
55
+ for a in apps:
56
+ latest = (
57
+ AppHealthLog.query.filter_by(app_id=a.id)
58
+ .order_by(AppHealthLog.checked_at.desc())
59
+ .first()
60
+ )
61
+ health_map[a.id] = latest
62
+ # Recent health history (for braille sparkline) — last 12 checks, oldest first
63
+ history = (
64
+ AppHealthLog.query.filter_by(app_id=a.id)
65
+ .order_by(AppHealthLog.checked_at.desc())
66
+ .limit(12)
67
+ .all()
68
+ )
69
+ health_history[a.id] = list(reversed(history))
70
+
71
+ # Role member counts
72
+ role_counts = {r.slug: User.query.filter_by(role=r.slug).count() for r in roles}
73
+
74
+ # Permission matrix map
75
+ perms_map = {(p.user_id, p.app_id): p.can_access for p in UserAppPermission.query.all()}
76
+
77
+ # Flair assignments
78
+ user_flairs = {u.id: get_user_flair(u.id) for u in users}
79
+
80
+ # Member list pagination context (for the members tab fragment)
81
+ from appmanager.admin.members import (
82
+ DEFAULT_PER_PAGE,
83
+ PER_PAGE_CHOICES,
84
+ app_access_counts,
85
+ list_members,
86
+ total_app_count,
87
+ )
88
+
89
+ members_pagination = list_members(page=1, per_page=DEFAULT_PER_PAGE)
90
+ members_access_counts = app_access_counts([u.id for u in members_pagination.items])
91
+
92
+ return render_template(
93
+ "admin/dashboard.html",
94
+ user=user,
95
+ apps=apps,
96
+ users=users,
97
+ roles=roles,
98
+ health_map=health_map,
99
+ health_history=health_history,
100
+ role_counts=role_counts,
101
+ perms_map=perms_map,
102
+ user_flairs=user_flairs,
103
+ presets=DEFAULT_PRESET_FLAIRS,
104
+ members_pagination=members_pagination,
105
+ pagination=members_pagination,
106
+ members_q="",
107
+ q="",
108
+ members_role="",
109
+ role="",
110
+ members_sort="last_login",
111
+ sort="last_login",
112
+ members_per_page=DEFAULT_PER_PAGE,
113
+ per_page=DEFAULT_PER_PAGE,
114
+ members_per_page_choices=PER_PAGE_CHOICES,
115
+ per_page_choices=PER_PAGE_CHOICES,
116
+ members_access_counts=members_access_counts,
117
+ access_counts=members_access_counts,
118
+ members_total_apps=total_app_count(),
119
+ total_apps=total_app_count(),
120
+ )
121
+
122
+
123
+ @admin_bp.route("/health/check-all", methods=["POST"])
124
+ @admin_required
125
+ def run_health_checks_route():
126
+ results = check_all_apps_health()
127
+ flash(f"Executed health checks for {len(results)} active applications.", "info")
128
+ return redirect(url_for("admin.dashboard"))
129
+
130
+
131
+ @admin_bp.route("/apps/precheck-git", methods=["POST"])
132
+ @admin_required
133
+ def precheck_git():
134
+ repo_url = (
135
+ request.form.get("repo_url")
136
+ or (request.json.get("repo_url") if request.is_json else "")
137
+ or ""
138
+ ).strip()
139
+ name = (
140
+ request.form.get("name") or (request.json.get("name") if request.is_json else "") or ""
141
+ ).strip()
142
+ slug = (
143
+ request.form.get("slug") or (request.json.get("slug") if request.is_json else "") or ""
144
+ ).strip() or None
145
+ entry_point = (
146
+ request.form.get("entry_point")
147
+ or (request.json.get("entry_point") if request.is_json else "")
148
+ or ""
149
+ ).strip() or None
150
+
151
+ if not repo_url or not name:
152
+ return jsonify({"success": False, "error": "Repository URL and Name are required."}), 400
153
+
154
+ try:
155
+ staging_id, scan_report, manifest = stage_git_repo(
156
+ repo_url=repo_url,
157
+ name=name,
158
+ slug=slug,
159
+ entry_point=entry_point,
160
+ )
161
+ return jsonify(
162
+ {
163
+ "success": True,
164
+ "staging_id": staging_id,
165
+ "report": scan_report.to_dict(),
166
+ "manifest": manifest,
167
+ "name": manifest.get("name") or name,
168
+ "slug": manifest.get("slug") or slug or sanitize_slug(name),
169
+ "entry_point": manifest.get("entry_point") or entry_point or "app:app",
170
+ }
171
+ )
172
+ except Exception as e:
173
+ return jsonify({"success": False, "error": str(e)}), 400
174
+
175
+
176
+ @admin_bp.route("/apps/precheck-zip", methods=["POST"])
177
+ @admin_required
178
+ def precheck_zip():
179
+ if "zip_file" not in request.files:
180
+ return jsonify({"success": False, "error": "No zip file uploaded."}), 400
181
+
182
+ file = request.files["zip_file"]
183
+ if file.filename == "":
184
+ return jsonify({"success": False, "error": "No selected zip file."}), 400
185
+
186
+ name = request.form.get("name", "").strip()
187
+ slug = request.form.get("slug", "").strip() or None
188
+ entry_point = request.form.get("entry_point", "").strip() or None
189
+
190
+ if not name:
191
+ return jsonify({"success": False, "error": "App Name is required."}), 400
192
+
193
+ try:
194
+ staging_id, scan_report, manifest = stage_zip_file(
195
+ zip_file_storage=file,
196
+ name=name,
197
+ slug=slug,
198
+ entry_point=entry_point,
199
+ )
200
+ return jsonify(
201
+ {
202
+ "success": True,
203
+ "staging_id": staging_id,
204
+ "report": scan_report.to_dict(),
205
+ "manifest": manifest,
206
+ "name": manifest.get("name") or name,
207
+ "slug": manifest.get("slug") or slug or sanitize_slug(name),
208
+ "entry_point": manifest.get("entry_point") or entry_point or "app:app",
209
+ }
210
+ )
211
+ except Exception as e:
212
+ return jsonify({"success": False, "error": str(e)}), 400
213
+
214
+
215
+ @admin_bp.route("/apps/install-confirm", methods=["POST"])
216
+ @admin_required
217
+ def install_confirm():
218
+ staging_id = (
219
+ request.form.get("staging_id")
220
+ or (request.json.get("staging_id") if request.is_json else "")
221
+ or ""
222
+ ).strip()
223
+ name = (
224
+ request.form.get("name") or (request.json.get("name") if request.is_json else "") or ""
225
+ ).strip() or None
226
+ slug = (
227
+ request.form.get("slug") or (request.json.get("slug") if request.is_json else "") or ""
228
+ ).strip() or None
229
+ entry_point = (
230
+ request.form.get("entry_point")
231
+ or (request.json.get("entry_point") if request.is_json else "")
232
+ or ""
233
+ ).strip() or None
234
+
235
+ if not staging_id:
236
+ if request.is_json:
237
+ return jsonify({"success": False, "error": "Missing staging ID."}), 400
238
+ flash("Missing staging ID.", "danger")
239
+ return redirect(url_for("admin.dashboard"))
240
+
241
+ try:
242
+ app_record = finalize_staged_installation(
243
+ staging_id=staging_id,
244
+ name=name,
245
+ slug=slug,
246
+ entry_point=entry_point,
247
+ )
248
+ if request.is_json:
249
+ return jsonify(
250
+ {
251
+ "success": True,
252
+ "app_id": app_record.id,
253
+ "name": app_record.name,
254
+ "slug": app_record.slug,
255
+ "message": f"Application '{app_record.name}' installed successfully!",
256
+ }
257
+ )
258
+ flash(f"Application '{app_record.name}' installed successfully!", "success")
259
+ except Exception as e:
260
+ if request.is_json:
261
+ return jsonify({"success": False, "error": str(e)}), 400
262
+ flash(f"Installation failed: {str(e)}", "danger")
263
+
264
+ return redirect(url_for("admin.dashboard"))
265
+
266
+
267
+ @admin_bp.route("/apps/cancel-staged", methods=["POST"])
268
+ @admin_required
269
+ def cancel_staged():
270
+ staging_id = (
271
+ request.form.get("staging_id")
272
+ or (request.json.get("staging_id") if request.is_json else "")
273
+ or ""
274
+ ).strip()
275
+ if staging_id:
276
+ cancel_staged_app(staging_id)
277
+ return jsonify({"success": True})
278
+
279
+
280
+ @admin_bp.route("/apps/install-git", methods=["POST"])
281
+ @admin_required
282
+ def install_git():
283
+ repo_url = request.form.get("repo_url", "").strip()
284
+ name = request.form.get("name", "").strip()
285
+ slug = request.form.get("slug", "").strip()
286
+ entry_point = request.form.get("entry_point", "").strip() or None
287
+
288
+ if not repo_url or not name:
289
+ flash("Repository URL and Name are required.", "danger")
290
+ return redirect(url_for("admin.dashboard"))
291
+
292
+ try:
293
+ app_record = install_from_git(
294
+ repo_url=repo_url, name=name, slug=slug, entry_point=entry_point
295
+ )
296
+ flash(f"Application '{app_record.name}' installed successfully from Git!", "success")
297
+ except Exception as e:
298
+ flash(f"Failed to install application from Git: {str(e)}", "danger")
299
+
300
+ return redirect(url_for("admin.dashboard"))
301
+
302
+
303
+ @admin_bp.route("/apps/install-zip", methods=["POST"])
304
+ @admin_required
305
+ def install_zip():
306
+ if "zip_file" not in request.files:
307
+ flash("No file part uploaded.", "danger")
308
+ return redirect(url_for("admin.dashboard"))
309
+
310
+ file = request.files["zip_file"]
311
+ if file.filename == "":
312
+ flash("No selected file.", "danger")
313
+ return redirect(url_for("admin.dashboard"))
314
+
315
+ name = request.form.get("name", "").strip()
316
+ slug = request.form.get("slug", "").strip()
317
+ entry_point = request.form.get("entry_point", "").strip() or None
318
+
319
+ if not name:
320
+ flash("App Name is required.", "danger")
321
+ return redirect(url_for("admin.dashboard"))
322
+
323
+ try:
324
+ app_record = install_from_zip(
325
+ zip_file_storage=file, name=name, slug=slug, entry_point=entry_point
326
+ )
327
+ flash(f"Application '{app_record.name}' uploaded and installed successfully!", "success")
328
+ except Exception as e:
329
+ flash(f"Failed to install application from ZIP: {str(e)}", "danger")
330
+
331
+ return redirect(url_for("admin.dashboard"))
332
+
333
+
334
+ @admin_bp.route("/apps/<int:app_id>/delete", methods=["POST"])
335
+ @admin_required
336
+ def delete_app(app_id):
337
+ success, msg = uninstall_app(app_id)
338
+ if success:
339
+ flash(msg, "success")
340
+ else:
341
+ flash(msg, "danger")
342
+ return redirect(url_for("admin.dashboard"))
343
+
344
+
345
+ @admin_bp.route("/apps/<int:app_id>/toggle-auth", methods=["POST"])
346
+ @admin_required
347
+ def toggle_auth(app_id):
348
+ app_record = db.session.get(InstalledApp, app_id)
349
+ if not app_record:
350
+ flash("App not found.", "danger")
351
+ return redirect(url_for("admin.dashboard"))
352
+
353
+ app_record.requires_auth = not app_record.requires_auth
354
+ db.session.commit()
355
+ status_str = "Login Protected" if app_record.requires_auth else "Public / Anonymous"
356
+ flash(f"Updated authentication setting for '{app_record.name}' to {status_str}.", "success")
357
+ return redirect(url_for("admin.dashboard"))
358
+
359
+
360
+ @admin_bp.route("/apps/<int:app_id>/set-default", methods=["POST"])
361
+ @admin_required
362
+ def set_default(app_id):
363
+ app_record = db.session.get(InstalledApp, app_id)
364
+ if not app_record:
365
+ flash("App not found.", "danger")
366
+ return redirect(url_for("admin.dashboard"))
367
+
368
+ # Unset default on all other apps
369
+ all_apps = InstalledApp.query.all()
370
+ for a in all_apps:
371
+ if a.id == app_record.id:
372
+ a.is_default = not a.is_default
373
+ else:
374
+ a.is_default = False
375
+
376
+ db.session.commit()
377
+ msg = (
378
+ f"'{app_record.name}' is now set as the Default Landing App!"
379
+ if app_record.is_default
380
+ else "Cleared default app setting."
381
+ )
382
+ flash(msg, "success")
383
+ return redirect(url_for("admin.dashboard"))
384
+
385
+
386
+ @admin_bp.route("/apps/<int:app_id>/toggle-active", methods=["POST"])
387
+ @admin_required
388
+ def toggle_active(app_id):
389
+ app_record = db.session.get(InstalledApp, app_id)
390
+ if not app_record:
391
+ flash("App not found.", "danger")
392
+ return redirect(url_for("admin.dashboard"))
393
+
394
+ app_record.is_active = not app_record.is_active
395
+ db.session.commit()
396
+
397
+ # Clear cache if deactivated
398
+ if (
399
+ not app_record.is_active
400
+ and hasattr(current_app, "extensions")
401
+ and "appmanager" in current_app.extensions
402
+ ):
403
+ current_app.extensions["appmanager"].clear_cache(slug=app_record.slug)
404
+
405
+ status_str = "Active / Visible" if app_record.is_active else "Inactive / Hidden"
406
+ flash(f"Updated status for '{app_record.name}' to {status_str}.", "success")
407
+ return redirect(url_for("admin.dashboard"))
408
+
409
+
410
+ @admin_bp.route("/apps/<int:app_id>/settings", methods=["POST"])
411
+ @admin_required
412
+ def update_app_settings(app_id):
413
+ import json
414
+
415
+ app_record = db.session.get(InstalledApp, app_id)
416
+ if not app_record:
417
+ flash("App not found.", "danger")
418
+ return redirect(url_for("admin.dashboard"))
419
+
420
+ raw_json = request.form.get("settings_json", "").strip()
421
+ if raw_json:
422
+ try:
423
+ parsed = json.loads(raw_json)
424
+ if not isinstance(parsed, dict):
425
+ raise ValueError("Settings must be a JSON object.")
426
+ app_record.set_settings(parsed)
427
+ db.session.commit()
428
+ flash(f"Updated configuration settings for '{app_record.name}'.", "success")
429
+ except Exception as e:
430
+ flash(f"Invalid JSON format for settings: {str(e)}", "danger")
431
+ else:
432
+ app_record.settings_json = None
433
+ db.session.commit()
434
+ flash(f"Cleared settings for '{app_record.name}'.", "info")
435
+
436
+ return redirect(url_for("admin.dashboard"))
437
+
438
+
439
+ @admin_bp.route("/apps/<slug>/settings", methods=["POST"])
440
+ @admin_required
441
+ def save_app_schema_settings(slug):
442
+ """Save a generated settings form (settings_schema) for an app."""
443
+ from appmanager.admin.registry import save_schema_form
444
+
445
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
446
+ if not app_record:
447
+ flash("App not found.", "danger")
448
+ return redirect(url_for("admin.dashboard"))
449
+ ok, msg = save_schema_form(app_record)
450
+ flash(msg, "success" if ok else "danger")
451
+ return redirect(url_for("admin.app_detail", slug=slug))
452
+
453
+
454
+ @admin_bp.route("/apps/<slug>")
455
+ @admin_required
456
+ def app_detail(slug):
457
+ """
458
+ App detail page: shows the app's own admin panels (generated settings form
459
+ and/or links to custom blueprint panels).
460
+ """
461
+ from appmanager.admin.registry import (
462
+ manifest_for,
463
+ panels_for,
464
+ render_panel_safe,
465
+ settings_schema_for,
466
+ )
467
+ from appmanager.app_config import get_configs
468
+
469
+ user = get_current_user()
470
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
471
+ if not app_record:
472
+ flash("App not found.", "danger")
473
+ return redirect(url_for("admin.dashboard"))
474
+
475
+ panels = panels_for(app_record)
476
+ schema = settings_schema_for(app_record)
477
+ configs = get_configs(app_record.id)
478
+ manifest = manifest_for(app_record)
479
+
480
+ # Render each panel's content safely (kill switch)
481
+ rendered = []
482
+ for panel in panels:
483
+ rendered.append(
484
+ {
485
+ "panel": panel,
486
+ "html": render_panel_safe(app_record, panel, configs=configs),
487
+ }
488
+ )
489
+
490
+ return render_template(
491
+ "admin/app_detail.html",
492
+ user=user,
493
+ app=app_record,
494
+ panels=panels,
495
+ rendered=rendered,
496
+ schema=schema,
497
+ configs=configs,
498
+ manifest=manifest,
499
+ )
500
+
501
+
502
+ @admin_bp.route("/permissions", methods=["GET", "POST"])
503
+ @admin_required
504
+ def permissions():
505
+ if request.method == "POST":
506
+ # Processing matrix update
507
+ users = User.query.all()
508
+ apps = InstalledApp.query.all()
509
+
510
+ for u in users:
511
+ for a in apps:
512
+ form_key = f"perm_{u.id}_{a.id}"
513
+ has_access = request.form.get(form_key) == "1"
514
+ perm = UserAppPermission.query.filter_by(user_id=u.id, app_id=a.id).first()
515
+ if not perm:
516
+ perm = UserAppPermission(user_id=u.id, app_id=a.id, can_access=has_access)
517
+ db.session.add(perm)
518
+ else:
519
+ perm.can_access = has_access
520
+
521
+ db.session.commit()
522
+ flash("App permissions updated successfully!", "success")
523
+ return redirect(url_for("admin.dashboard"))
524
+
525
+ users = User.query.order_by(User.email).all()
526
+ apps = InstalledApp.query.order_by(InstalledApp.name).all()
527
+
528
+ # Map current permissions for fast lookup
529
+ perms_map = {}
530
+ all_perms = UserAppPermission.query.all()
531
+ for p in all_perms:
532
+ perms_map[(p.user_id, p.app_id)] = p.can_access
533
+
534
+ return render_template("admin/permissions.html", users=users, apps=apps, perms_map=perms_map)
535
+
536
+
537
+ @admin_bp.route("/users")
538
+ @admin_required
539
+ def users_list():
540
+ user = get_current_user()
541
+ users = User.query.order_by(User.created_at.desc()).all()
542
+ apps = InstalledApp.query.order_by(InstalledApp.name).all()
543
+ roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
544
+ perms_map = {(p.user_id, p.app_id): p.can_access for p in UserAppPermission.query.all()}
545
+ return render_template(
546
+ "admin/users.html", user=user, users=users, apps=apps, roles=roles, perms_map=perms_map
547
+ )
548
+
549
+
550
+ @admin_bp.route("/users/<int:user_id>/edit", methods=["POST"])
551
+ @admin_required
552
+ def edit_user(user_id):
553
+ u = db.session.get(User, user_id)
554
+ if not u:
555
+ flash("User not found.", "danger")
556
+ return redirect(url_for("admin.users_list"))
557
+
558
+ u.name = request.form.get("name", "").strip() or u.name
559
+ u.role = request.form.get("role", "user").strip().lower()
560
+ u.is_active = request.form.get("is_active") == "1"
561
+
562
+ # Update app permissions for this user
563
+ apps = InstalledApp.query.all()
564
+ for app in apps:
565
+ form_key = f"perm_{app.id}"
566
+ has_access = request.form.get(form_key) == "1"
567
+ perm = UserAppPermission.query.filter_by(user_id=u.id, app_id=app.id).first()
568
+ if not perm:
569
+ perm = UserAppPermission(user_id=u.id, app_id=app.id, can_access=has_access)
570
+ db.session.add(perm)
571
+ else:
572
+ perm.can_access = has_access
573
+
574
+ # Update flair for this user if provided
575
+ flair_title = request.form.get("flair_title")
576
+ flair_color = request.form.get("flair_color", "#a855f7")
577
+ if flair_title is not None:
578
+ from appmanager.extensions import set_user_flair
579
+
580
+ set_user_flair(u.id, flair_title, flair_color)
581
+
582
+ db.session.commit()
583
+ flash(f"Successfully updated user details, permissions, and flair for '{u.email}'.", "success")
584
+ return redirect(url_for("admin.dashboard"))
585
+
586
+
587
+ @admin_bp.route("/roles", methods=["GET"])
588
+ @admin_required
589
+ def manage_roles():
590
+ user = get_current_user()
591
+ roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
592
+
593
+ # Compute member count per role
594
+ role_counts = {}
595
+ for r in roles:
596
+ role_counts[r.slug] = User.query.filter_by(role=r.slug).count()
597
+
598
+ return render_template("admin/roles.html", user=user, roles=roles, role_counts=role_counts)
599
+
600
+
601
+ @admin_bp.route("/roles/create", methods=["POST"])
602
+ @admin_required
603
+ def create_role():
604
+ name = request.form.get("name", "").strip()
605
+ slug = request.form.get("slug", "").strip() or sanitize_slug(name)
606
+ description = request.form.get("description", "").strip()
607
+
608
+ if not name:
609
+ flash("Role name is required.", "danger")
610
+ return redirect(url_for("admin.dashboard"))
611
+
612
+ clean_slug = sanitize_slug(slug)
613
+ existing = Role.query.filter((Role.slug == clean_slug) | (Role.name == name)).first()
614
+ if existing:
615
+ flash(f"A role with name '{name}' or slug '{clean_slug}' already exists.", "warning")
616
+ return redirect(url_for("admin.dashboard"))
617
+
618
+ new_role = Role(name=name, slug=clean_slug, description=description, is_system=False)
619
+ db.session.add(new_role)
620
+ db.session.commit()
621
+ flash(f"Role '{name}' (slug: {clean_slug}) created successfully!", "success")
622
+ return redirect(url_for("admin.dashboard"))
623
+
624
+
625
+ @admin_bp.route("/roles/<int:role_id>/edit", methods=["POST"])
626
+ @admin_required
627
+ def edit_role(role_id):
628
+ role_record = db.session.get(Role, role_id)
629
+ if not role_record:
630
+ flash("Role not found.", "danger")
631
+ return redirect(url_for("admin.manage_roles"))
632
+
633
+ name = request.form.get("name", "").strip()
634
+ description = request.form.get("description", "").strip()
635
+
636
+ if name and not role_record.is_system:
637
+ role_record.name = name
638
+ role_record.description = description
639
+ db.session.commit()
640
+ flash(f"Updated role '{role_record.name}'.", "success")
641
+ return redirect(url_for("admin.dashboard"))
642
+
643
+
644
+ @admin_bp.route("/roles/<int:role_id>/delete", methods=["POST"])
645
+ @admin_required
646
+ def delete_role(role_id):
647
+ role_record = db.session.get(Role, role_id)
648
+ if not role_record:
649
+ flash("Role not found.", "danger")
650
+ return redirect(url_for("admin.manage_roles"))
651
+
652
+ if role_record.is_system:
653
+ flash(f"Cannot delete system role '{role_record.name}'.", "danger")
654
+ return redirect(url_for("admin.dashboard"))
655
+
656
+ # Reassign any users with this role to 'user'
657
+ affected_users = User.query.filter_by(role=role_record.slug).all()
658
+ for u in affected_users:
659
+ u.role = "user"
660
+
661
+ db.session.delete(role_record)
662
+ db.session.commit()
663
+ flash(
664
+ f"Deleted role '{role_record.name}'. Reassigned {len(affected_users)} member(s) to 'user'.",
665
+ "success",
666
+ )
667
+ return redirect(url_for("admin.dashboard"))
668
+
669
+
670
+ @admin_bp.route("/flairs", methods=["GET", "POST"])
671
+ @admin_required
672
+ def manage_flairs():
673
+ from appmanager.extensions import DEFAULT_PRESET_FLAIRS, get_user_flair, set_user_flair
674
+
675
+ if request.method == "POST":
676
+ user_id = request.form.get("user_id", type=int)
677
+ flair_title = request.form.get("flair_title", "").strip()
678
+ flair_color = request.form.get("flair_color", "#a855f7").strip()
679
+ action = request.form.get("action", "save")
680
+
681
+ if not user_id:
682
+ flash("Invalid user selection.", "danger")
683
+ return redirect(url_for("admin.dashboard"))
684
+
685
+ if action == "clear":
686
+ set_user_flair(user_id, "", "#a855f7")
687
+ flash(f"Cleared flair for user #{user_id}.", "info")
688
+ else:
689
+ set_user_flair(user_id, flair_title, flair_color)
690
+ flash(f"Updated flair for user #{user_id} successfully!", "success")
691
+
692
+ return redirect(url_for("admin.dashboard"))
693
+
694
+ users = User.query.order_by(User.created_at.desc()).all()
695
+ user_flairs = {u.id: get_user_flair(u.id) for u in users}
696
+ return render_template(
697
+ "admin/flairs.html", users=users, user_flairs=user_flairs, presets=DEFAULT_PRESET_FLAIRS
698
+ )
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # Member management (scalable) — paginated list + detail drawer + bulk ops
703
+ # ---------------------------------------------------------------------------
704
+
705
+
706
+ @admin_bp.route("/members")
707
+ @admin_required
708
+ def members_list():
709
+ """
710
+ Paginated, searchable member list. Renders a fragment when requested via
711
+ htmx (HX-Request header) so the table can refresh in place; otherwise
712
+ renders the full dashboard with the members tab active.
713
+ """
714
+ from appmanager.extensions import get_user_flair
715
+
716
+ user = get_current_user()
717
+ q = request.args.get("q", "").strip()
718
+ role = request.args.get("role", "").strip() or None
719
+ sort = request.args.get("sort", "last_login")
720
+ page = request.args.get("page", 1, type=int)
721
+ per_page = request.args.get("per_page", DEFAULT_PER_PAGE, type=int)
722
+
723
+ pagination = list_members(q=q, role=role, page=page, per_page=per_page, sort=sort)
724
+ users = pagination.items
725
+ user_ids = [u.id for u in users]
726
+ access_counts = app_access_counts(user_ids)
727
+ user_flairs = {u.id: get_user_flair(u.id) for u in users}
728
+ roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
729
+ total_apps = total_app_count()
730
+
731
+ ctx = dict(
732
+ user=user,
733
+ users=users,
734
+ roles=roles,
735
+ pagination=pagination,
736
+ q=q,
737
+ role=role,
738
+ sort=sort,
739
+ per_page=per_page,
740
+ per_page_choices=PER_PAGE_CHOICES,
741
+ access_counts=access_counts,
742
+ user_flairs=user_flairs,
743
+ total_apps=total_apps,
744
+ )
745
+
746
+ if request.headers.get("HX-Request"):
747
+ return render_template("admin/_members_table.html", **ctx)
748
+
749
+ # Full page render: reuse the dashboard shell but activate the members tab
750
+ apps = InstalledApp.query.order_by(InstalledApp.installed_at.desc()).all()
751
+ health_map = {}
752
+ health_history = {}
753
+ for a in apps:
754
+ latest = (
755
+ AppHealthLog.query.filter_by(app_id=a.id)
756
+ .order_by(AppHealthLog.checked_at.desc())
757
+ .first()
758
+ )
759
+ health_map[a.id] = latest
760
+ history = (
761
+ AppHealthLog.query.filter_by(app_id=a.id)
762
+ .order_by(AppHealthLog.checked_at.desc())
763
+ .limit(12)
764
+ .all()
765
+ )
766
+ health_history[a.id] = list(reversed(history))
767
+ role_counts = {r.slug: User.query.filter_by(role=r.slug).count() for r in roles}
768
+ perms_map = {(p.user_id, p.app_id): p.can_access for p in UserAppPermission.query.all()}
769
+ from appmanager.extensions import DEFAULT_PRESET_FLAIRS
770
+
771
+ return render_template(
772
+ "admin/dashboard.html",
773
+ user=user,
774
+ apps=apps,
775
+ users=users,
776
+ roles=roles,
777
+ health_map=health_map,
778
+ health_history=health_history,
779
+ role_counts=role_counts,
780
+ perms_map=perms_map,
781
+ user_flairs=user_flairs,
782
+ presets=DEFAULT_PRESET_FLAIRS,
783
+ active_tab="members",
784
+ members_pagination=pagination,
785
+ pagination=pagination,
786
+ members_q=q,
787
+ q=q,
788
+ members_role=role,
789
+ role=role,
790
+ members_sort=sort,
791
+ sort=sort,
792
+ members_per_page=per_page,
793
+ per_page=per_page,
794
+ members_per_page_choices=PER_PAGE_CHOICES,
795
+ per_page_choices=PER_PAGE_CHOICES,
796
+ members_access_counts=access_counts,
797
+ access_counts=access_counts,
798
+ members_total_apps=total_apps,
799
+ total_apps=total_apps,
800
+ )
801
+
802
+
803
+ @admin_bp.route("/members/<int:user_id>")
804
+ @admin_required
805
+ def member_detail(user_id):
806
+ """Renders the member edit drawer content (fragment)."""
807
+ from appmanager.extensions import DEFAULT_PRESET_FLAIRS, get_user_flair
808
+
809
+ u = db.session.get(User, user_id)
810
+ if not u:
811
+ return ("", 404)
812
+ roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
813
+ perms = member_permissions(user_id)
814
+ flair = get_user_flair(user_id)
815
+ return render_template(
816
+ "admin/_member_drawer.html",
817
+ u=u,
818
+ roles=roles,
819
+ perms=perms,
820
+ flair=flair,
821
+ presets=DEFAULT_PRESET_FLAIRS,
822
+ )
823
+
824
+
825
+ @admin_bp.route("/members/<int:user_id>/edit", methods=["POST"])
826
+ @admin_required
827
+ def edit_member(user_id):
828
+ """Save member identity/role/status/permissions/flair. Returns to members list."""
829
+ u = db.session.get(User, user_id)
830
+ if not u:
831
+ flash("User not found.", "danger")
832
+ return redirect(url_for("admin.members_list"))
833
+
834
+ u.name = request.form.get("name", "").strip() or u.name
835
+ u.role = request.form.get("role", "user").strip().lower()
836
+ u.is_active = request.form.get("is_active") == "1"
837
+
838
+ # Update app permissions for this user (skip if admin — inherited)
839
+ if not u.is_admin():
840
+ apps = InstalledApp.query.all()
841
+ for app in apps:
842
+ form_key = f"perm_{app.id}"
843
+ has_access = request.form.get(form_key) == "1"
844
+ perm = UserAppPermission.query.filter_by(user_id=u.id, app_id=app.id).first()
845
+ if not perm:
846
+ perm = UserAppPermission(user_id=u.id, app_id=app.id, can_access=has_access)
847
+ db.session.add(perm)
848
+ else:
849
+ perm.can_access = has_access
850
+
851
+ # Update flair if provided
852
+ flair_title = request.form.get("flair_title")
853
+ flair_color = request.form.get("flair_color", "#F5A524")
854
+ if flair_title is not None:
855
+ from appmanager.extensions import set_user_flair
856
+
857
+ set_user_flair(u.id, flair_title, flair_color)
858
+
859
+ db.session.commit()
860
+ flash(f"Updated member '{u.email}'.", "success")
861
+ return redirect(url_for("admin.members_list"))
862
+
863
+
864
+ @admin_bp.route("/members/bulk", methods=["POST"])
865
+ @admin_required
866
+ def members_bulk():
867
+ """Bulk actions on selected members: set role, deactivate, activate, delete."""
868
+ action = request.form.get("action", "")
869
+ ids = [int(x) for x in request.form.getlist("user_ids") if x.isdigit()]
870
+ if not ids:
871
+ flash("No members selected.", "warning")
872
+ return redirect(url_for("admin.members_list"))
873
+
874
+ users = User.query.filter(User.id.in_(ids)).all()
875
+ if action == "set_role":
876
+ role = request.form.get("role", "").strip().lower()
877
+ if role:
878
+ for u in users:
879
+ u.role = role
880
+ db.session.commit()
881
+ flash(f"Set role '{role}' on {len(users)} member(s).", "success")
882
+ elif action == "deactivate":
883
+ for u in users:
884
+ u.is_active = False
885
+ db.session.commit()
886
+ flash(f"Deactivated {len(users)} member(s).", "success")
887
+ elif action == "activate":
888
+ for u in users:
889
+ u.is_active = True
890
+ db.session.commit()
891
+ flash(f"Activated {len(users)} member(s).", "success")
892
+ elif action == "delete":
893
+ for u in users:
894
+ db.session.delete(u)
895
+ db.session.commit()
896
+ flash(f"Deleted {len(users)} member(s).", "success")
897
+ else:
898
+ flash("Unknown bulk action.", "warning")
899
+
900
+ return redirect(url_for("admin.members_list"))