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,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Member list/detail query helpers for the admin console.
|
|
3
|
+
|
|
4
|
+
Keeps the member-management data access out of routes.py so the
|
|
5
|
+
pagination/search/aggregation logic is testable in isolation and the
|
|
6
|
+
N+1 permission lookups are contained in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func, or_
|
|
10
|
+
|
|
11
|
+
from appmanager.database import db
|
|
12
|
+
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
13
|
+
|
|
14
|
+
DEFAULT_PER_PAGE = 25
|
|
15
|
+
PER_PAGE_CHOICES = (25, 50, 100)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_members(q=None, role=None, page=1, per_page=DEFAULT_PER_PAGE, sort="last_login"):
|
|
19
|
+
"""
|
|
20
|
+
Server-side paginated + filtered member list.
|
|
21
|
+
|
|
22
|
+
Returns a SQLAlchemy Pagination object. Never loads the whole table.
|
|
23
|
+
"""
|
|
24
|
+
if per_page not in PER_PAGE_CHOICES:
|
|
25
|
+
per_page = DEFAULT_PER_PAGE
|
|
26
|
+
|
|
27
|
+
query = User.query
|
|
28
|
+
|
|
29
|
+
if q:
|
|
30
|
+
like = f"%{q.strip()}%"
|
|
31
|
+
query = query.filter(or_(User.name.ilike(like), User.email.ilike(like)))
|
|
32
|
+
if role:
|
|
33
|
+
query = query.filter_by(role=role)
|
|
34
|
+
|
|
35
|
+
if sort == "name":
|
|
36
|
+
query = query.order_by(User.name.asc())
|
|
37
|
+
elif sort == "email":
|
|
38
|
+
query = query.order_by(User.email.asc())
|
|
39
|
+
elif sort == "created":
|
|
40
|
+
query = query.order_by(User.created_at.desc())
|
|
41
|
+
elif sort == "logins":
|
|
42
|
+
query = query.order_by(User.login_count.desc())
|
|
43
|
+
else: # last_login
|
|
44
|
+
query = query.order_by(User.last_login_at.desc().nullslast())
|
|
45
|
+
|
|
46
|
+
return query.paginate(page=page, per_page=per_page, error_out=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def app_access_counts(user_ids):
|
|
50
|
+
"""
|
|
51
|
+
One aggregate query: {user_id: count_of_apps_they_can_access}.
|
|
52
|
+
Replaces the per-user N+1 permission lookups in the member table.
|
|
53
|
+
"""
|
|
54
|
+
if not user_ids:
|
|
55
|
+
return {}
|
|
56
|
+
rows = (
|
|
57
|
+
db.session.query(UserAppPermission.user_id, func.count(UserAppPermission.id))
|
|
58
|
+
.filter(UserAppPermission.user_id.in_(user_ids))
|
|
59
|
+
.filter(UserAppPermission.can_access.is_(True))
|
|
60
|
+
.group_by(UserAppPermission.user_id)
|
|
61
|
+
.all()
|
|
62
|
+
)
|
|
63
|
+
return {uid: cnt for uid, cnt in rows}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def total_app_count():
|
|
67
|
+
"""Number of installed apps (for the 'x of N apps' readout)."""
|
|
68
|
+
return InstalledApp.query.count()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def member_permissions(user_id):
|
|
72
|
+
"""
|
|
73
|
+
Full per-app access list for a single member (used by the edit drawer).
|
|
74
|
+
Returns list of dicts: {app_id, name, slug, can_access, inherited}.
|
|
75
|
+
"""
|
|
76
|
+
user = db.session.get(User, user_id)
|
|
77
|
+
if not user:
|
|
78
|
+
return []
|
|
79
|
+
apps = InstalledApp.query.order_by(InstalledApp.name.asc()).all()
|
|
80
|
+
perms = {
|
|
81
|
+
p.app_id: p.can_access for p in UserAppPermission.query.filter_by(user_id=user_id).all()
|
|
82
|
+
}
|
|
83
|
+
inherited = user.is_admin()
|
|
84
|
+
result = []
|
|
85
|
+
for a in apps:
|
|
86
|
+
result.append(
|
|
87
|
+
{
|
|
88
|
+
"app_id": a.id,
|
|
89
|
+
"name": a.name,
|
|
90
|
+
"slug": a.slug,
|
|
91
|
+
"can_access": perms.get(a.id, True),
|
|
92
|
+
"inherited": inherited,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return result
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extension admin registry — the manifest contract.
|
|
3
|
+
|
|
4
|
+
Each installed app can declare its own admin surface in its `manifest.json`:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"name": "User Flairs",
|
|
8
|
+
"slug": "user-flairs",
|
|
9
|
+
"type": "extension",
|
|
10
|
+
"admin_sections": [
|
|
11
|
+
{"id": "presets", "label": "Flair presets", "icon": "tag",
|
|
12
|
+
"blueprint": "flairs_admin:presets", "order": 10}
|
|
13
|
+
],
|
|
14
|
+
"settings_schema": [
|
|
15
|
+
{"key": "max_flairs", "type": "integer", "label": "Max flairs per member", "default": 3}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Two mechanisms:
|
|
20
|
+
* `settings_schema` → the framework renders a generated typed form (zero code).
|
|
21
|
+
* `admin_sections` → the extension ships its own Flask blueprint; the framework
|
|
22
|
+
mounts it at `/admin/apps/<slug>/<panel>` with admin auth
|
|
23
|
+
enforced at mount time (never trusted to the extension).
|
|
24
|
+
|
|
25
|
+
The registry persists declared panels to `app_admin_panels` on install/upgrade
|
|
26
|
+
and provides a kill switch so a broken panel never takes down the rest of /admin.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import traceback
|
|
33
|
+
|
|
34
|
+
from flask import Blueprint, current_app, redirect, request, url_for
|
|
35
|
+
|
|
36
|
+
from appmanager.database import db
|
|
37
|
+
from appmanager.models import AppAdminPanel, InstalledApp
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Manifest parsing
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_manifest(app_dir):
|
|
45
|
+
"""Parse manifest.json from an installed app's directory."""
|
|
46
|
+
manifest_path = os.path.join(app_dir, "manifest.json")
|
|
47
|
+
if not os.path.exists(manifest_path):
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
return data if isinstance(data, dict) else {}
|
|
53
|
+
except Exception:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def app_dir_for(app_record):
|
|
58
|
+
"""Resolve the on-disk directory for an installed app."""
|
|
59
|
+
base = current_app.config.get("INSTALLED_APPS_DIR")
|
|
60
|
+
return os.path.join(base, app_record.slug) if base else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def manifest_for(app_record):
|
|
64
|
+
"""Fetch the manifest dict for an installed app (empty if none)."""
|
|
65
|
+
d = app_dir_for(app_record)
|
|
66
|
+
if not d:
|
|
67
|
+
return {}
|
|
68
|
+
return parse_manifest(d)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Persistence
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sync_panels(app_record, manifest=None):
|
|
77
|
+
"""
|
|
78
|
+
Reconcile `app_admin_panels` rows with the app's manifest. Called on
|
|
79
|
+
install and on upgrade/reload. Returns the list of panel dicts.
|
|
80
|
+
"""
|
|
81
|
+
manifest = manifest or manifest_for(app_record)
|
|
82
|
+
declared = manifest.get("admin_sections", []) or []
|
|
83
|
+
# Remove panels no longer declared
|
|
84
|
+
declared_ids = {p.get("id") for p in declared if p.get("id")}
|
|
85
|
+
AppAdminPanel.query.filter_by(app_id=app_record.id).filter(
|
|
86
|
+
~AppAdminPanel.panel_id.in_(declared_ids) if declared_ids else True
|
|
87
|
+
).delete(synchronize_session=False)
|
|
88
|
+
|
|
89
|
+
for i, p in enumerate(declared):
|
|
90
|
+
pid = p.get("id")
|
|
91
|
+
if not pid:
|
|
92
|
+
continue
|
|
93
|
+
row = AppAdminPanel.query.filter_by(app_id=app_record.id, panel_id=pid).first()
|
|
94
|
+
if not row:
|
|
95
|
+
row = AppAdminPanel(app_id=app_record.id, panel_id=pid)
|
|
96
|
+
db.session.add(row)
|
|
97
|
+
row.label = p.get("label", pid)
|
|
98
|
+
row.icon = p.get("icon")
|
|
99
|
+
row.endpoint = p.get("blueprint")
|
|
100
|
+
row.sort_order = p.get("order", i * 10)
|
|
101
|
+
db.session.commit()
|
|
102
|
+
return [
|
|
103
|
+
p.to_dict()
|
|
104
|
+
for p in AppAdminPanel.query.filter_by(app_id=app_record.id)
|
|
105
|
+
.order_by(AppAdminPanel.sort_order)
|
|
106
|
+
.all()
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def panels_for(app_record):
|
|
111
|
+
"""Ordered list of panel dicts for an app."""
|
|
112
|
+
rows = (
|
|
113
|
+
AppAdminPanel.query.filter_by(app_id=app_record.id).order_by(AppAdminPanel.sort_order).all()
|
|
114
|
+
)
|
|
115
|
+
return [r.to_dict() for r in rows]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def settings_schema_for(app_record):
|
|
119
|
+
"""The app's declared settings_schema (list of descriptors)."""
|
|
120
|
+
manifest = manifest_for(app_record)
|
|
121
|
+
schema = manifest.get("settings_schema", []) or []
|
|
122
|
+
return schema if isinstance(schema, list) else []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Blueprint mounting (with admin auth enforced at mount time)
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_blueprint(endpoint_ref):
|
|
131
|
+
"""
|
|
132
|
+
Resolve 'module:blueprint_var' or 'module:blueprint_var:endpoint' to a
|
|
133
|
+
(blueprint, endpoint_name) tuple. Returns (None, None) on any failure.
|
|
134
|
+
"""
|
|
135
|
+
if not endpoint_ref:
|
|
136
|
+
return None, None
|
|
137
|
+
parts = endpoint_ref.split(":")
|
|
138
|
+
if len(parts) < 2:
|
|
139
|
+
return None, None
|
|
140
|
+
module_path, attr = parts[0], parts[1]
|
|
141
|
+
endpoint = parts[2] if len(parts) > 2 else None
|
|
142
|
+
try:
|
|
143
|
+
module = importlib.import_module(module_path)
|
|
144
|
+
bp = getattr(module, attr)
|
|
145
|
+
if not isinstance(bp, Blueprint):
|
|
146
|
+
return None, None
|
|
147
|
+
return bp, endpoint
|
|
148
|
+
except Exception:
|
|
149
|
+
return None, None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def mount_app_admin_blueprints(app_record, admin_bp):
|
|
153
|
+
"""
|
|
154
|
+
Mount an app's declared admin blueprints under `/admin/apps/<slug>/<panel>`.
|
|
155
|
+
Admin auth is enforced via a before_request on the mounted blueprint — the
|
|
156
|
+
extension is never trusted to self-guard. Returns (mounted, errors).
|
|
157
|
+
"""
|
|
158
|
+
from appmanager.auth.utils import get_current_user
|
|
159
|
+
|
|
160
|
+
def _admin_guard():
|
|
161
|
+
"""before_request guard: 401/redirect unless the caller is an admin."""
|
|
162
|
+
from flask import request
|
|
163
|
+
|
|
164
|
+
user = get_current_user()
|
|
165
|
+
if not user:
|
|
166
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
167
|
+
return {"error": "Authentication required"}, 401
|
|
168
|
+
return redirect(url_for("auth.login", next=request.url))
|
|
169
|
+
if not user.is_admin():
|
|
170
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
171
|
+
return {"error": "Admin privilege required"}, 403
|
|
172
|
+
return redirect(url_for("auth.profile"))
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
mounted = []
|
|
176
|
+
errors = []
|
|
177
|
+
for panel in panels_for(app_record):
|
|
178
|
+
if not panel.get("endpoint"):
|
|
179
|
+
continue # generated-form panel, no blueprint to mount
|
|
180
|
+
bp, endpoint = _resolve_blueprint(panel["endpoint"])
|
|
181
|
+
if bp is None:
|
|
182
|
+
errors.append(
|
|
183
|
+
f"Panel '{panel['panel_id']}': could not resolve blueprint '{panel['endpoint']}'"
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Namespace the blueprint under /admin/apps/<slug>/<panel_id>
|
|
188
|
+
# (admin_bp already carries the /admin prefix, so nest under /apps/...)
|
|
189
|
+
url_prefix = f"/apps/{app_record.slug}/{panel['panel_id']}"
|
|
190
|
+
bp.url_prefix = url_prefix
|
|
191
|
+
# Enforce admin auth at mount time — non-negotiable
|
|
192
|
+
bp.before_request(_admin_guard)
|
|
193
|
+
try:
|
|
194
|
+
admin_bp.register_blueprint(bp)
|
|
195
|
+
mounted.append(panel["panel_id"])
|
|
196
|
+
except Exception as e:
|
|
197
|
+
errors.append(f"Panel '{panel['panel_id']}': mount failed: {e}")
|
|
198
|
+
return mounted, errors
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def mount_all_app_admin_blueprints(admin_bp):
|
|
202
|
+
"""
|
|
203
|
+
Mount admin blueprints for every installed app that declares them.
|
|
204
|
+
Called once at startup. Failures are logged, never fatal.
|
|
205
|
+
"""
|
|
206
|
+
apps = InstalledApp.query.all()
|
|
207
|
+
for a in apps:
|
|
208
|
+
try:
|
|
209
|
+
# Reconcile declared panels with the manifest on every startup so
|
|
210
|
+
# manifest edits are picked up without a manual reinstall.
|
|
211
|
+
sync_panels(a)
|
|
212
|
+
mount_app_admin_blueprints(a, admin_bp)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"[ADMIN REGISTRY] Failed to mount panels for '{a.slug}': {e}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Generated settings form (settings_schema → form)
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_settings_form(app_record, configs):
|
|
223
|
+
"""
|
|
224
|
+
Render a generated settings form from the app's settings_schema.
|
|
225
|
+
Returns HTML string, or None if the app has no schema.
|
|
226
|
+
"""
|
|
227
|
+
schema = settings_schema_for(app_record)
|
|
228
|
+
if not schema:
|
|
229
|
+
return None
|
|
230
|
+
from markupsafe import Markup
|
|
231
|
+
|
|
232
|
+
from appmanager.app_config import get_configs
|
|
233
|
+
|
|
234
|
+
configs = configs or get_configs(app_record.id)
|
|
235
|
+
rows = []
|
|
236
|
+
for desc in schema:
|
|
237
|
+
key = desc.get("key")
|
|
238
|
+
if not key:
|
|
239
|
+
continue
|
|
240
|
+
label = desc.get("label", key)
|
|
241
|
+
vtype = desc.get("type", "json")
|
|
242
|
+
value = configs.get(key, desc.get("default"))
|
|
243
|
+
rows.append(_schema_field(key, label, vtype, value, desc))
|
|
244
|
+
body = "\n".join(rows)
|
|
245
|
+
return Markup(
|
|
246
|
+
f'<form method="POST" action="/admin/apps/{app_record.slug}/settings" class="schema-form">'
|
|
247
|
+
f'<input type="hidden" name="csrf_token" value="{_csrf()}">'
|
|
248
|
+
f"{body}"
|
|
249
|
+
f'<div class="action-group mt-2"><button type="submit" class="btn btn-primary btn-sm">Save Settings</button></div>'
|
|
250
|
+
f"</form>"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _csrf():
|
|
255
|
+
try:
|
|
256
|
+
from appmanager.security import generate_csrf_token
|
|
257
|
+
|
|
258
|
+
return generate_csrf_token()
|
|
259
|
+
except Exception:
|
|
260
|
+
return ""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _schema_field(key, label, vtype, value, desc):
|
|
264
|
+
"""Render a single schema field as HTML."""
|
|
265
|
+
if vtype == "boolean":
|
|
266
|
+
checked = " checked" if value else ""
|
|
267
|
+
return (
|
|
268
|
+
f'<div class="form-group"><label class="form-label">{label}</label>'
|
|
269
|
+
f'<label class="flex-center" style="gap:0.5rem;cursor:pointer;">'
|
|
270
|
+
f'<input type="checkbox" name="cfg_{key}" value="1"{checked} class="perm-toggle">'
|
|
271
|
+
f'<span class="text-muted" style="font-size:0.8rem;">enabled</span></label></div>'
|
|
272
|
+
)
|
|
273
|
+
if vtype == "color":
|
|
274
|
+
val = value or "#F5A524"
|
|
275
|
+
return (
|
|
276
|
+
f'<div class="form-group"><label class="form-label">{label}</label>'
|
|
277
|
+
f'<input type="color" name="cfg_{key}" value="{val}" '
|
|
278
|
+
f'style="height:36px;width:60px;padding:0;border:none;background:none;cursor:pointer;"></div>'
|
|
279
|
+
)
|
|
280
|
+
if vtype == "integer":
|
|
281
|
+
val = value if value is not None else ""
|
|
282
|
+
return (
|
|
283
|
+
f'<div class="form-group"><label class="form-label">{label}</label>'
|
|
284
|
+
f'<input type="number" name="cfg_{key}" value="{val}" class="form-input"></div>'
|
|
285
|
+
)
|
|
286
|
+
if vtype == "textarea":
|
|
287
|
+
val = value if value is not None else ""
|
|
288
|
+
return (
|
|
289
|
+
f'<div class="form-group"><label class="form-label">{label}</label>'
|
|
290
|
+
f'<textarea name="cfg_{key}" class="form-textarea" rows="3">{val}</textarea></div>'
|
|
291
|
+
)
|
|
292
|
+
# string / json default
|
|
293
|
+
val = value if value is not None else ""
|
|
294
|
+
if isinstance(val, (dict, list)):
|
|
295
|
+
val = json.dumps(val)
|
|
296
|
+
return (
|
|
297
|
+
f'<div class="form-group"><label class="form-label">{label}</label>'
|
|
298
|
+
f'<input type="text" name="cfg_{key}" value="{val}" class="form-input"></div>'
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def save_schema_form(app_record):
|
|
303
|
+
"""
|
|
304
|
+
Persist a submitted generated settings form. Reads `cfg_*` fields and
|
|
305
|
+
writes typed config rows. Returns (ok, message).
|
|
306
|
+
"""
|
|
307
|
+
from appmanager.app_config import set_config
|
|
308
|
+
|
|
309
|
+
schema = settings_schema_for(app_record)
|
|
310
|
+
if not schema:
|
|
311
|
+
return False, "No settings schema declared for this app."
|
|
312
|
+
for desc in schema:
|
|
313
|
+
key = desc.get("key")
|
|
314
|
+
if not key:
|
|
315
|
+
continue
|
|
316
|
+
vtype = desc.get("type", "json")
|
|
317
|
+
field = f"cfg_{key}"
|
|
318
|
+
if vtype == "boolean":
|
|
319
|
+
value = request.form.get(field) == "1"
|
|
320
|
+
elif vtype == "integer":
|
|
321
|
+
raw = request.form.get(field, "").strip()
|
|
322
|
+
value = int(raw) if raw else None
|
|
323
|
+
elif vtype == "json":
|
|
324
|
+
raw = request.form.get(field, "").strip()
|
|
325
|
+
try:
|
|
326
|
+
value = json.loads(raw) if raw else None
|
|
327
|
+
except Exception:
|
|
328
|
+
return False, f"Invalid JSON for '{key}'."
|
|
329
|
+
else:
|
|
330
|
+
value = request.form.get(field, "")
|
|
331
|
+
set_config(
|
|
332
|
+
app_record.id, key, value, value_type=vtype, is_secret=desc.get("is_secret", False)
|
|
333
|
+
)
|
|
334
|
+
return True, "Settings saved."
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Kill switch — render a panel safely, never take down /admin
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def render_panel_safe(app_record, panel, **ctx):
|
|
343
|
+
"""
|
|
344
|
+
Render a single admin panel's content. If the panel is a generated form,
|
|
345
|
+
render it. If it's a blueprint panel, we can't easily call its endpoint
|
|
346
|
+
here — instead we link to it. Any error renders an inline error card.
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
if not panel.get("endpoint"):
|
|
350
|
+
from appmanager.app_config import get_configs
|
|
351
|
+
|
|
352
|
+
form = render_settings_form(app_record, get_configs(app_record.id))
|
|
353
|
+
if form is None:
|
|
354
|
+
return '<div class="empty-state"><p>No settings schema or admin panel declared for this app.</p></div>'
|
|
355
|
+
return form
|
|
356
|
+
# Blueprint panel — render a link card (content lives at its own route)
|
|
357
|
+
url = f"/admin/apps/{app_record.slug}/{panel['panel_id']}"
|
|
358
|
+
return (
|
|
359
|
+
f'<div class="panel-link-card">'
|
|
360
|
+
f'<p class="text-muted">This app provides a custom admin panel.</p>'
|
|
361
|
+
f'<a href="{url}" class="btn btn-primary btn-sm">Open {panel["label"]}</a>'
|
|
362
|
+
f"</div>"
|
|
363
|
+
)
|
|
364
|
+
except Exception as e:
|
|
365
|
+
tb = traceback.format_exc()
|
|
366
|
+
return (
|
|
367
|
+
f'<div class="alert alert-danger">'
|
|
368
|
+
f"<strong>Panel error:</strong> {e}"
|
|
369
|
+
f'<details class="mt-1"><summary class="mono" style="font-size:0.75rem;cursor:pointer;">traceback</summary>'
|
|
370
|
+
f'<pre class="mono" style="font-size:0.7rem;overflow:auto;max-height:200px;">{tb}</pre></details>'
|
|
371
|
+
f"</div>"
|
|
372
|
+
)
|