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.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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"))
|