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,902 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Admin — AppManager{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="page-header">
|
|
7
|
+
<div>
|
|
8
|
+
<h1 class="page-title">Admin</h1>
|
|
9
|
+
<p class="page-subtitle">Manage sub-apps, members, roles, flairs & access</p>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="action-group">
|
|
12
|
+
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('run-health-form').submit();">↻ Run Health Checks</button>
|
|
13
|
+
</div>
|
|
14
|
+
<form id="run-health-form" action="/admin/health/check-all" method="POST" style="display:none;"></form>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<!-- Stat readouts (label-over-number, no boxes) -->
|
|
18
|
+
<div class="stat-grid">
|
|
19
|
+
<div class="stat-readout">
|
|
20
|
+
<div class="stat-label">Applications</div>
|
|
21
|
+
<div class="stat-value">{{ apps|length }}</div>
|
|
22
|
+
<div class="stat-sub">{{ apps|selectattr('is_active')|list|length }} active</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="stat-readout">
|
|
25
|
+
<div class="stat-label">Members</div>
|
|
26
|
+
<div class="stat-value">{{ users|length }}</div>
|
|
27
|
+
<div class="stat-sub">{{ users|selectattr('is_online')|list|length }} online</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="stat-readout">
|
|
30
|
+
<div class="stat-label">Roles</div>
|
|
31
|
+
<div class="stat-value">{{ roles|length }}</div>
|
|
32
|
+
<div class="stat-sub">{{ roles|selectattr('is_system')|list|length }} system</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="stat-readout">
|
|
35
|
+
<div class="stat-label">Extensions</div>
|
|
36
|
+
<div class="stat-value">{{ apps|selectattr('app_type', 'equalto', 'extension')|list|length }}</div>
|
|
37
|
+
<div class="stat-sub">UI slot plugins</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="admin-layout">
|
|
42
|
+
<!-- Sidebar nav -->
|
|
43
|
+
<aside class="admin-sidebar">
|
|
44
|
+
<nav class="admin-nav">
|
|
45
|
+
<a href="#apps" class="active" data-tab="apps"><span class="nav-icon">▸</span> Applications</a>
|
|
46
|
+
<a href="#users" data-tab="users"><span class="nav-icon">▸</span> Members</a>
|
|
47
|
+
<a href="#access" data-tab="access"><span class="nav-icon">▸</span> Access Management</a>
|
|
48
|
+
</nav>
|
|
49
|
+
</aside>
|
|
50
|
+
|
|
51
|
+
<!-- Content -->
|
|
52
|
+
<div class="admin-content">
|
|
53
|
+
|
|
54
|
+
<!-- ============ APPS TAB ============ -->
|
|
55
|
+
<section class="tab-panel active" id="panel-apps">
|
|
56
|
+
<div class="grid grid-2 mb-4">
|
|
57
|
+
<!-- Git Install -->
|
|
58
|
+
<div class="card">
|
|
59
|
+
<h2 class="card-title mb-3">Pull & Install from GitHub</h2>
|
|
60
|
+
<form id="form-install-git" action="/admin/apps/install-git" method="POST">
|
|
61
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
62
|
+
<div class="form-group">
|
|
63
|
+
<label class="form-label">Repository URL</label>
|
|
64
|
+
<input type="url" name="repo_url" class="form-input" placeholder="https://github.com/user/flask-app.git" required>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="form-group">
|
|
67
|
+
<label class="form-label">App Display Name</label>
|
|
68
|
+
<input type="text" name="name" class="form-input" placeholder="e.g. My Dashboard" required>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="grid grid-2">
|
|
71
|
+
<div class="form-group">
|
|
72
|
+
<label class="form-label">URL Slug</label>
|
|
73
|
+
<input type="text" name="slug" class="form-input" placeholder="my-dashboard">
|
|
74
|
+
</div>
|
|
75
|
+
<div class="form-group">
|
|
76
|
+
<label class="form-label">Entrypoint</label>
|
|
77
|
+
<input type="text" name="entry_point" class="form-input" placeholder="app:app">
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="submit" id="btn-install-git" class="btn btn-primary btn-block">Pull Git Repository & Pre-Check</button>
|
|
81
|
+
</form>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- ZIP Install -->
|
|
85
|
+
<div class="card">
|
|
86
|
+
<h2 class="card-title mb-3">Upload & Install ZIP</h2>
|
|
87
|
+
<form id="form-install-zip" action="/admin/apps/install-zip" method="POST" enctype="multipart/form-data">
|
|
88
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
89
|
+
<div class="form-group">
|
|
90
|
+
<label class="form-label">Select ZIP File</label>
|
|
91
|
+
<input type="file" id="zip_file_input" name="zip_file" class="form-input" accept=".zip" required>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="form-group">
|
|
94
|
+
<label class="form-label">App Display Name</label>
|
|
95
|
+
<input type="text" id="zip_app_name" name="name" class="form-input" placeholder="e.g. Analytics Tool" required>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="grid grid-2">
|
|
98
|
+
<div class="form-group">
|
|
99
|
+
<label class="form-label">URL Slug</label>
|
|
100
|
+
<input type="text" id="zip_app_slug" name="slug" class="form-input" placeholder="analytics">
|
|
101
|
+
</div>
|
|
102
|
+
<div class="form-group">
|
|
103
|
+
<label class="form-label">Entrypoint</label>
|
|
104
|
+
<input type="text" name="entry_point" class="form-input" placeholder="app:app">
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<button type="submit" id="btn-install-zip" class="btn btn-primary btn-block">Upload & Pre-Check ZIP</button>
|
|
108
|
+
</form>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- Installed apps table -->
|
|
113
|
+
<div class="card">
|
|
114
|
+
<div class="card-header">
|
|
115
|
+
<h2 class="card-title mb-0">Installed Sub-Applications</h2>
|
|
116
|
+
<span class="badge badge-primary">{{ apps|length }} total</span>
|
|
117
|
+
</div>
|
|
118
|
+
{% if apps %}
|
|
119
|
+
<div class="table-wrap">
|
|
120
|
+
<table class="table table-apps">
|
|
121
|
+
<thead>
|
|
122
|
+
<tr>
|
|
123
|
+
<th>Application</th>
|
|
124
|
+
<th>State</th>
|
|
125
|
+
<th>Health</th>
|
|
126
|
+
<th class="actions-col">Actions</th>
|
|
127
|
+
</tr>
|
|
128
|
+
</thead>
|
|
129
|
+
<tbody>
|
|
130
|
+
{% for app in apps %}
|
|
131
|
+
{% set hlog = health_map.get(app.id) %}
|
|
132
|
+
{% set hhist = health_history.get(app.id, []) %}
|
|
133
|
+
<tr>
|
|
134
|
+
<td class="app-name-cell">
|
|
135
|
+
<div class="app-name-row">
|
|
136
|
+
<strong>{{ app.name }}</strong>
|
|
137
|
+
{% if app.is_default %}<span class="badge badge-warning">Default</span>{% endif %}
|
|
138
|
+
{% if app.app_type == 'extension' %}<span class="badge badge-accent">Ext</span>{% endif %}
|
|
139
|
+
</div>
|
|
140
|
+
<div class="app-path mono">/apps/{{ app.slug }}</div>
|
|
141
|
+
</td>
|
|
142
|
+
<td>
|
|
143
|
+
<div class="state-cell">
|
|
144
|
+
<form class="inline-form" action="/admin/apps/{{ app.id }}/toggle-active" method="POST">
|
|
145
|
+
{% if app.is_active %}
|
|
146
|
+
<button type="submit" class="state-toggle state-on" title="Click to disable/hide app"><span class="badge-dot"></span> Active</button>
|
|
147
|
+
{% else %}
|
|
148
|
+
<button type="submit" class="state-toggle state-off" title="Click to activate/enable app">Disabled</button>
|
|
149
|
+
{% endif %}
|
|
150
|
+
</form>
|
|
151
|
+
<form class="inline-form" action="/admin/apps/{{ app.id }}/toggle-auth" method="POST">
|
|
152
|
+
{% if app.requires_auth %}
|
|
153
|
+
<button type="submit" class="state-toggle state-lock" title="Click to make public">🔒 Protected</button>
|
|
154
|
+
{% else %}
|
|
155
|
+
<button type="submit" class="state-toggle state-open" title="Click to require login">🌐 Public</button>
|
|
156
|
+
{% endif %}
|
|
157
|
+
</form>
|
|
158
|
+
</div>
|
|
159
|
+
</td>
|
|
160
|
+
<td>
|
|
161
|
+
<div class="health-cell">
|
|
162
|
+
{% if hlog %}
|
|
163
|
+
{% if hlog.status == 'healthy' %}
|
|
164
|
+
<span class="badge badge-success badge-dot">Healthy</span>
|
|
165
|
+
{% elif hlog.status == 'degraded' %}
|
|
166
|
+
<span class="badge badge-warning badge-dot">Degraded</span>
|
|
167
|
+
{% else %}
|
|
168
|
+
<span class="badge badge-danger badge-dot">Down</span>
|
|
169
|
+
{% endif %}
|
|
170
|
+
{% else %}
|
|
171
|
+
<span class="badge badge-muted">—</span>
|
|
172
|
+
{% endif %}
|
|
173
|
+
{% if hhist %}
|
|
174
|
+
<span class="sparkline" title="Health history (oldest → newest)">{{ render_sparkline(hhist) }}</span>
|
|
175
|
+
{% endif %}
|
|
176
|
+
</div>
|
|
177
|
+
</td>
|
|
178
|
+
<td class="actions-col">
|
|
179
|
+
<div class="row-actions">
|
|
180
|
+
<a href="/admin/apps/{{ app.slug }}" class="btn btn-ghost btn-sm icon-btn" title="Configure App Settings">⚙</a>
|
|
181
|
+
{% if app.has_web_ui %}
|
|
182
|
+
<a href="/apps/{{ app.slug }}/" class="btn btn-secondary btn-sm">Launch</a>
|
|
183
|
+
{% endif %}
|
|
184
|
+
<form class="inline-form" action="/admin/apps/{{ app.id }}/set-default" method="POST">
|
|
185
|
+
{% if app.is_default %}
|
|
186
|
+
<button type="submit" class="btn btn-warning btn-sm icon-btn" title="Unset default landing app">★</button>
|
|
187
|
+
{% else %}
|
|
188
|
+
<button type="submit" class="btn btn-ghost btn-sm" title="Set as default landing app">Default</button>
|
|
189
|
+
{% endif %}
|
|
190
|
+
</form>
|
|
191
|
+
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('uninstall-{{ app.id }}', 'Uninstall {{ app.name }}?', 'Type <code>{{ app.slug }}</code> to confirm')">Uninstall</button>
|
|
192
|
+
</div>
|
|
193
|
+
</td>
|
|
194
|
+
</tr>
|
|
195
|
+
{% endfor %}
|
|
196
|
+
</tbody>
|
|
197
|
+
</table>
|
|
198
|
+
</div>
|
|
199
|
+
{% else %}
|
|
200
|
+
<div class="empty-state">
|
|
201
|
+
<h2>No sub-applications installed</h2>
|
|
202
|
+
<p>Use the forms above to install your first sub-app.</p>
|
|
203
|
+
</div>
|
|
204
|
+
{% endif %}
|
|
205
|
+
</div>
|
|
206
|
+
</section>
|
|
207
|
+
|
|
208
|
+
<!-- ============ MEMBERS TAB ============ -->
|
|
209
|
+
<section class="tab-panel" id="panel-users">
|
|
210
|
+
<div class="card">
|
|
211
|
+
<div class="card-header">
|
|
212
|
+
<h2 class="card-title mb-0">Registered Members</h2>
|
|
213
|
+
<span class="badge badge-primary">{{ members_pagination.total if members_pagination else users|length }} total</span>
|
|
214
|
+
</div>
|
|
215
|
+
<div id="members-table-wrap">
|
|
216
|
+
{% if members_pagination %}
|
|
217
|
+
{% include 'admin/_members_table.html' %}
|
|
218
|
+
{% elif users %}
|
|
219
|
+
{# Legacy fallback: render the full list inline (no pagination context) #}
|
|
220
|
+
<div class="table-wrap">
|
|
221
|
+
<table class="table table-members">
|
|
222
|
+
<thead>
|
|
223
|
+
<tr>
|
|
224
|
+
<th>Member</th>
|
|
225
|
+
<th>Role</th>
|
|
226
|
+
<th>Status</th>
|
|
227
|
+
<th>Last Login</th>
|
|
228
|
+
<th class="actions-col">Actions</th>
|
|
229
|
+
</tr>
|
|
230
|
+
</thead>
|
|
231
|
+
<tbody>
|
|
232
|
+
{% for u in users %}
|
|
233
|
+
<tr>
|
|
234
|
+
<td class="member-cell">
|
|
235
|
+
<div class="member-name-row"><strong>{{ u.name or u.email }}</strong> {{ render_user_flair_badge(u.id) }}</div>
|
|
236
|
+
<div class="member-email mono">{{ u.email }}</div>
|
|
237
|
+
</td>
|
|
238
|
+
<td>
|
|
239
|
+
{% if u.role == 'admin' %}<span class="badge badge-accent">Admin</span>{% else %}<span class="badge badge-primary">{{ u.role }}</span>{% endif %}
|
|
240
|
+
</td>
|
|
241
|
+
<td>
|
|
242
|
+
{% if u.is_online() %}<span class="badge badge-success badge-dot">Online</span>{% else %}<span class="badge badge-muted badge-dot">Offline</span>{% endif %}
|
|
243
|
+
{% if not u.is_active %}<span class="badge badge-danger">Disabled</span>{% endif %}
|
|
244
|
+
</td>
|
|
245
|
+
<td class="mono">{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') if u.last_login_at else 'Never' }}</td>
|
|
246
|
+
<td class="actions-col"><button type="button" class="btn btn-ghost btn-sm" onclick="openMemberDrawer({{ u.id }})">Edit</button></td>
|
|
247
|
+
</tr>
|
|
248
|
+
{% endfor %}
|
|
249
|
+
</tbody>
|
|
250
|
+
</table>
|
|
251
|
+
</div>
|
|
252
|
+
{% else %}
|
|
253
|
+
<div class="empty-state">
|
|
254
|
+
<h2>No registered members</h2>
|
|
255
|
+
<p>Members appear here once they register or log in.</p>
|
|
256
|
+
</div>
|
|
257
|
+
{% endif %}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<!-- ============ ACCESS MANAGEMENT TAB ============ -->
|
|
263
|
+
<section class="tab-panel" id="panel-access">
|
|
264
|
+
<!-- Roles -->
|
|
265
|
+
<div class="grid grid-2 mb-4">
|
|
266
|
+
<div class="card">
|
|
267
|
+
<h2 class="card-title mb-3">Create New Role</h2>
|
|
268
|
+
<form action="/admin/roles/create" method="POST">
|
|
269
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
270
|
+
<div class="form-group">
|
|
271
|
+
<label class="form-label">Role Display Name</label>
|
|
272
|
+
<input type="text" name="name" class="form-input" placeholder="e.g. Content Editor" required>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="form-group">
|
|
275
|
+
<label class="form-label">Role Slug (identifier)</label>
|
|
276
|
+
<input type="text" name="slug" class="form-input" placeholder="e.g. content-editor">
|
|
277
|
+
</div>
|
|
278
|
+
<div class="form-group">
|
|
279
|
+
<label class="form-label">Description</label>
|
|
280
|
+
<textarea name="description" class="form-textarea" style="height:80px;" placeholder="Describe what responsibilities or access this role grants..."></textarea>
|
|
281
|
+
</div>
|
|
282
|
+
<button type="submit" class="btn btn-primary btn-block">Create Custom Role</button>
|
|
283
|
+
</form>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div class="card">
|
|
287
|
+
<div class="card-header">
|
|
288
|
+
<h2 class="card-title mb-0">Configured Roles</h2>
|
|
289
|
+
<span class="badge badge-primary">{{ roles|length }}</span>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="table-wrap">
|
|
292
|
+
<table class="table">
|
|
293
|
+
<thead>
|
|
294
|
+
<tr>
|
|
295
|
+
<th>Role</th>
|
|
296
|
+
<th>Slug</th>
|
|
297
|
+
<th>Type</th>
|
|
298
|
+
<th class="num">Members</th>
|
|
299
|
+
<th>Actions</th>
|
|
300
|
+
</tr>
|
|
301
|
+
</thead>
|
|
302
|
+
<tbody>
|
|
303
|
+
{% for r in roles %}
|
|
304
|
+
<tr>
|
|
305
|
+
<td><strong>{{ r.name }}</strong></td>
|
|
306
|
+
<td><code>{{ r.slug }}</code></td>
|
|
307
|
+
<td>
|
|
308
|
+
{% if r.is_system %}
|
|
309
|
+
<span class="badge badge-accent">System</span>
|
|
310
|
+
{% else %}
|
|
311
|
+
<span class="badge badge-primary">Custom</span>
|
|
312
|
+
{% endif %}
|
|
313
|
+
</td>
|
|
314
|
+
<td class="num">{{ role_counts.get(r.slug, 0) }}</td>
|
|
315
|
+
<td>
|
|
316
|
+
{% if not r.is_system %}
|
|
317
|
+
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('role-{{ r.id }}', 'Delete role {{ r.name }}?', 'Type <code>{{ r.slug }}</code> to confirm')">Delete</button>
|
|
318
|
+
{% else %}
|
|
319
|
+
<span class="text-faint" style="font-size:0.75rem;">Protected</span>
|
|
320
|
+
{% endif %}
|
|
321
|
+
</td>
|
|
322
|
+
</tr>
|
|
323
|
+
{% endfor %}
|
|
324
|
+
</tbody>
|
|
325
|
+
</table>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- Flairs -->
|
|
331
|
+
<div class="grid grid-2 mb-4">
|
|
332
|
+
<div class="card">
|
|
333
|
+
<h2 class="card-title mb-2" style="color: var(--accent);">Preset Flair Badges</h2>
|
|
334
|
+
<p class="text-muted mb-2" style="font-size:0.85rem;">Click any preset flair to quickly apply it in the member assignment table.</p>
|
|
335
|
+
<div class="flex" style="flex-direction:column; gap:0.6rem;">
|
|
336
|
+
{% for preset in presets %}
|
|
337
|
+
<div class="flex-between" style="background: var(--bg-elevated); padding:0.6rem 0.8rem; border-radius:var(--radius); border:1px solid var(--border);">
|
|
338
|
+
<span class="badge" style="color: {{ preset.color }}; border-color: {{ preset.color }}66;">{{ preset.title }}</span>
|
|
339
|
+
<span class="text-muted mono" style="font-size:0.72rem;">{{ preset.color }}</span>
|
|
340
|
+
</div>
|
|
341
|
+
{% endfor %}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div class="card">
|
|
346
|
+
<div class="card-header">
|
|
347
|
+
<h2 class="card-title mb-0">Member Flair Assignments</h2>
|
|
348
|
+
<span class="badge badge-primary">{{ users|length }}</span>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="table-wrap">
|
|
351
|
+
<table class="table">
|
|
352
|
+
<thead>
|
|
353
|
+
<tr>
|
|
354
|
+
<th>Member</th>
|
|
355
|
+
<th>Current Flair</th>
|
|
356
|
+
<th>Assign / Update</th>
|
|
357
|
+
</tr>
|
|
358
|
+
</thead>
|
|
359
|
+
<tbody>
|
|
360
|
+
{% for u in users %}
|
|
361
|
+
{% set f = user_flairs.get(u.id) %}
|
|
362
|
+
<tr>
|
|
363
|
+
<td>
|
|
364
|
+
<strong>{{ u.name or u.email }}</strong>
|
|
365
|
+
<div class="text-muted" style="font-size:0.75rem;">{{ u.email }}</div>
|
|
366
|
+
</td>
|
|
367
|
+
<td>
|
|
368
|
+
{% if f and f.title %}
|
|
369
|
+
<span class="badge" style="color: {{ f.color }}; border-color: {{ f.color }}66;">{{ f.title }}</span>
|
|
370
|
+
{% else %}
|
|
371
|
+
<span class="text-faint" style="font-size:0.75rem;">No Flair</span>
|
|
372
|
+
{% endif %}
|
|
373
|
+
</td>
|
|
374
|
+
<td>
|
|
375
|
+
<form action="/admin/flairs" method="POST" class="flex gap-1" style="align-items:center;">
|
|
376
|
+
<input type="hidden" name="user_id" value="{{ u.id }}">
|
|
377
|
+
<input type="text" name="flair_title" placeholder="Flair title..." value="{{ f.title if f else '' }}" class="form-input" style="width:130px; padding:0.3rem; font-size:0.8rem;">
|
|
378
|
+
<input type="color" name="flair_color" value="{{ f.color if f else '#F5A524' }}" style="height:28px; width:32px; padding:0; border:none; background:none; cursor:pointer;">
|
|
379
|
+
<button type="submit" name="action" value="save" class="btn btn-primary btn-sm">Save</button>
|
|
380
|
+
{% if f and f.title %}
|
|
381
|
+
<button type="submit" name="action" value="clear" class="btn btn-secondary btn-sm" title="Clear Flair">✕</button>
|
|
382
|
+
{% endif %}
|
|
383
|
+
</form>
|
|
384
|
+
</td>
|
|
385
|
+
</tr>
|
|
386
|
+
{% endfor %}
|
|
387
|
+
</tbody>
|
|
388
|
+
</table>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<!-- Permissions matrix -->
|
|
394
|
+
<div class="card">
|
|
395
|
+
<div class="card-header">
|
|
396
|
+
<h2 class="card-title mb-0">App Access Permissions Matrix</h2>
|
|
397
|
+
<span class="badge badge-primary">{{ users|length }} × {{ apps|length }}</span>
|
|
398
|
+
</div>
|
|
399
|
+
{% if users and apps %}
|
|
400
|
+
<form action="/admin/permissions" method="POST">
|
|
401
|
+
<div class="table-wrap mb-3">
|
|
402
|
+
<table class="table" style="text-align:center;">
|
|
403
|
+
<thead>
|
|
404
|
+
<tr>
|
|
405
|
+
<th style="text-align:left;">User / Email</th>
|
|
406
|
+
<th style="text-align:left;">Role</th>
|
|
407
|
+
{% for app in apps %}
|
|
408
|
+
<th>
|
|
409
|
+
<div>{{ app.name }}</div>
|
|
410
|
+
<div class="text-muted" style="font-size:0.7rem; font-weight:normal;">/apps/{{ app.slug }}</div>
|
|
411
|
+
</th>
|
|
412
|
+
{% endfor %}
|
|
413
|
+
</tr>
|
|
414
|
+
</thead>
|
|
415
|
+
<tbody>
|
|
416
|
+
{% for u in users %}
|
|
417
|
+
<tr>
|
|
418
|
+
<td style="text-align:left;">
|
|
419
|
+
<strong>{{ u.name or u.email }}</strong><br>
|
|
420
|
+
<span class="text-muted" style="font-size:0.78rem;">{{ u.email }}</span>
|
|
421
|
+
</td>
|
|
422
|
+
<td style="text-align:left;">
|
|
423
|
+
<span class="badge badge-primary">{{ u.role }}</span>
|
|
424
|
+
</td>
|
|
425
|
+
{% for app in apps %}
|
|
426
|
+
{% set is_checked = perms_map.get((u.id, app.id), True) %}
|
|
427
|
+
<td>
|
|
428
|
+
{% if u.is_admin() %}
|
|
429
|
+
<span class="text-success" style="font-size:0.8rem;">Full</span>
|
|
430
|
+
{% else %}
|
|
431
|
+
<input type="checkbox" name="perm_{{ u.id }}_{{ app.id }}" value="1" class="checkbox-cell" {% if is_checked %}checked{% endif %} style="transform:scale(1.2); cursor:pointer;">
|
|
432
|
+
{% endif %}
|
|
433
|
+
</td>
|
|
434
|
+
{% endfor %}
|
|
435
|
+
</tr>
|
|
436
|
+
{% endfor %}
|
|
437
|
+
</tbody>
|
|
438
|
+
</table>
|
|
439
|
+
</div>
|
|
440
|
+
<button type="submit" class="btn btn-primary">Save Permission Changes</button>
|
|
441
|
+
</form>
|
|
442
|
+
{% else %}
|
|
443
|
+
<div class="empty-state">
|
|
444
|
+
<h2>Nothing to manage yet</h2>
|
|
445
|
+
<p>You need both registered users and installed sub-apps to manage permissions.</p>
|
|
446
|
+
</div>
|
|
447
|
+
{% endif %}
|
|
448
|
+
</div>
|
|
449
|
+
</section>
|
|
450
|
+
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<!-- Type-to-confirm modal -->
|
|
455
|
+
<div id="confirm-modal" class="modal-overlay" style="display:none;">
|
|
456
|
+
<div class="modal">
|
|
457
|
+
<h3 id="confirm-title" class="mb-2" style="font-size:1.05rem;"></h3>
|
|
458
|
+
<p id="confirm-desc" class="text-muted mb-3" style="font-size:0.9rem;"></p>
|
|
459
|
+
<form id="confirm-form" method="POST" action="">
|
|
460
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
461
|
+
<div class="form-group">
|
|
462
|
+
<label class="form-label">Type the slug to confirm</label>
|
|
463
|
+
<input type="text" id="confirm-input" class="form-input" autocomplete="off" placeholder="type to confirm...">
|
|
464
|
+
</div>
|
|
465
|
+
<div class="action-group">
|
|
466
|
+
<button type="submit" id="confirm-submit" class="btn btn-danger" disabled>Confirm Delete</button>
|
|
467
|
+
<button type="button" class="btn btn-secondary" onclick="closeConfirm()">Cancel</button>
|
|
468
|
+
</div>
|
|
469
|
+
</form>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<!-- Security Pre-Check Loading Modal -->
|
|
474
|
+
<div id="security-loading-modal" class="modal-overlay" style="display:none;">
|
|
475
|
+
<div class="modal scanner-loader">
|
|
476
|
+
<div class="scanner-spinner"></div>
|
|
477
|
+
<h3 id="security-loading-title" class="mb-1" style="font-size:1.05rem;">Security Pre-Check in Progress</h3>
|
|
478
|
+
<p id="security-loading-msg" class="text-muted mono" style="font-size:0.85rem;">Staging package & running static AST security audit...</p>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<!-- Security Pre-Check Results & Confirmation Modal -->
|
|
483
|
+
<div id="security-modal" class="modal-overlay" style="display:none;">
|
|
484
|
+
<div class="modal modal-lg">
|
|
485
|
+
<div class="security-header">
|
|
486
|
+
<div>
|
|
487
|
+
<h3 class="mb-0" style="font-size:1.1rem; display:flex; align-items:center; gap:8px;">
|
|
488
|
+
<span>🛡️ Security Pre-Check Audit</span>
|
|
489
|
+
</h3>
|
|
490
|
+
<div id="sec-app-info" class="text-muted mono" style="font-size:0.8rem; margin-top:2px;"></div>
|
|
491
|
+
</div>
|
|
492
|
+
<span id="sec-risk-badge" class="badge">CLEAN</span>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
<div class="modal-scroll-body">
|
|
496
|
+
<!-- Stats -->
|
|
497
|
+
<div class="security-stats">
|
|
498
|
+
<div class="security-stat-box">
|
|
499
|
+
<div id="sec-stat-files" class="security-stat-val">0</div>
|
|
500
|
+
<div class="security-stat-lbl">Files Scanned</div>
|
|
501
|
+
</div>
|
|
502
|
+
<div class="security-stat-box">
|
|
503
|
+
<div id="sec-stat-py" class="security-stat-val">0</div>
|
|
504
|
+
<div class="security-stat-lbl">Python Modules</div>
|
|
505
|
+
</div>
|
|
506
|
+
<div class="security-stat-box">
|
|
507
|
+
<div id="sec-stat-findings" class="security-stat-val">0</div>
|
|
508
|
+
<div class="security-stat-lbl">Security Findings</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<!-- Summary Alert -->
|
|
513
|
+
<div id="sec-summary-banner" class="alert mb-3"></div>
|
|
514
|
+
|
|
515
|
+
<!-- Findings List -->
|
|
516
|
+
<div class="mb-2">
|
|
517
|
+
<h4 class="form-label mb-2" style="font-size:0.85rem; text-transform:uppercase; letter-spacing:0.5px;">Detailed Analysis Findings</h4>
|
|
518
|
+
<div id="sec-findings-container"></div>
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
<!-- Confirmation Prompt -->
|
|
522
|
+
<div class="card p-3 mb-0" style="background:var(--surface-2); border:1px solid var(--border);">
|
|
523
|
+
<p id="sec-confirm-prompt" class="mb-0" style="font-size:0.9rem; font-weight:600;">
|
|
524
|
+
Pre-installation checks are complete. Would you like to proceed with installing this application into AppManager?
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
|
|
529
|
+
<!-- Footer Actions -->
|
|
530
|
+
<div class="action-group" style="justify-content: flex-end; border-top: 1px solid var(--border); padding-top: var(--sp-3);">
|
|
531
|
+
<button type="button" id="btn-cancel-security" class="btn btn-secondary">Cancel Installation</button>
|
|
532
|
+
<button type="button" id="btn-confirm-security" class="btn btn-primary">Proceed & Install Application</button>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
<!-- Member edit drawer (slide-over) -->
|
|
538
|
+
<div class="drawer-overlay" id="member-drawer-overlay" onclick="if(event.target===this)closeMemberDrawer()">
|
|
539
|
+
<aside class="member-drawer" id="member-drawer">
|
|
540
|
+
<div id="member-drawer-content">
|
|
541
|
+
<div class="drawer-loading mono">LOADING MEMBER…</div>
|
|
542
|
+
</div>
|
|
543
|
+
</aside>
|
|
544
|
+
</div>
|
|
545
|
+
{% endblock %}
|
|
546
|
+
|
|
547
|
+
{% block extra_js %}
|
|
548
|
+
<script>
|
|
549
|
+
// Tab switching via sidebar links
|
|
550
|
+
document.querySelectorAll('.admin-nav a').forEach(function (link) {
|
|
551
|
+
link.addEventListener('click', function (e) {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
activateTab(link.getAttribute('data-tab'));
|
|
554
|
+
history.replaceState(null, '', '#' + link.getAttribute('data-tab'));
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
function activateTab(tab) {
|
|
559
|
+
document.querySelectorAll('.admin-nav a').forEach(function (a) {
|
|
560
|
+
a.classList.toggle('active', a.getAttribute('data-tab') === tab);
|
|
561
|
+
});
|
|
562
|
+
document.querySelectorAll('.tab-panel').forEach(function (p) {
|
|
563
|
+
p.classList.toggle('active', p.id === 'panel-' + tab);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Deep-link to a tab on load
|
|
568
|
+
(function () {
|
|
569
|
+
var hash = window.location.hash.replace('#', '');
|
|
570
|
+
if (hash && document.getElementById('panel-' + hash)) {
|
|
571
|
+
activateTab(hash);
|
|
572
|
+
}
|
|
573
|
+
})();
|
|
574
|
+
|
|
575
|
+
function toggleRow(id) {
|
|
576
|
+
var el = document.getElementById(id);
|
|
577
|
+
if (el) el.style.display = el.style.display === 'none' ? 'table-row' : 'none';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Type-to-confirm destructive action modal
|
|
581
|
+
var confirmSlug = '';
|
|
582
|
+
function openConfirm(slug, title, desc) {
|
|
583
|
+
confirmSlug = slug;
|
|
584
|
+
document.getElementById('confirm-title').textContent = title;
|
|
585
|
+
document.getElementById('confirm-desc').innerHTML = desc;
|
|
586
|
+
document.getElementById('confirm-input').value = '';
|
|
587
|
+
document.getElementById('confirm-submit').disabled = true;
|
|
588
|
+
document.getElementById('confirm-form').action = '/admin/apps/' + slug.split('-').pop() + '/delete';
|
|
589
|
+
// For roles, the action differs
|
|
590
|
+
if (slug.indexOf('role-') === 0) {
|
|
591
|
+
document.getElementById('confirm-form').action = '/admin/roles/' + slug.split('-').pop() + '/delete';
|
|
592
|
+
}
|
|
593
|
+
document.getElementById('confirm-modal').style.display = 'flex';
|
|
594
|
+
document.getElementById('confirm-input').focus();
|
|
595
|
+
}
|
|
596
|
+
function closeConfirm() {
|
|
597
|
+
document.getElementById('confirm-modal').style.display = 'none';
|
|
598
|
+
}
|
|
599
|
+
document.getElementById('confirm-input').addEventListener('input', function (e) {
|
|
600
|
+
document.getElementById('confirm-submit').disabled = (e.target.value.trim() !== confirmSlug.split('-').pop());
|
|
601
|
+
});
|
|
602
|
+
document.getElementById('confirm-modal').addEventListener('click', function (e) {
|
|
603
|
+
if (e.target === this) closeConfirm();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ---- Member drawer (slide-over) ----
|
|
607
|
+
function openMemberDrawer(userId) {
|
|
608
|
+
var overlay = document.getElementById('member-drawer-overlay');
|
|
609
|
+
var content = document.getElementById('member-drawer-content');
|
|
610
|
+
content.innerHTML = '<div class="drawer-loading mono">LOADING MEMBER…</div>';
|
|
611
|
+
overlay.classList.add('open');
|
|
612
|
+
document.body.classList.add('drawer-open');
|
|
613
|
+
fetch('/admin/members/' + userId, { headers: { 'HX-Request': 'true' } })
|
|
614
|
+
.then(function (r) { return r.text(); })
|
|
615
|
+
.then(function (html) { content.innerHTML = html; })
|
|
616
|
+
.catch(function () { content.innerHTML = '<div class="drawer-error mono">Failed to load member.</div>'; });
|
|
617
|
+
}
|
|
618
|
+
function closeMemberDrawer() {
|
|
619
|
+
document.getElementById('member-drawer-overlay').classList.remove('open');
|
|
620
|
+
document.body.classList.remove('drawer-open');
|
|
621
|
+
}
|
|
622
|
+
document.addEventListener('keydown', function (e) {
|
|
623
|
+
if (e.key === 'Escape') closeMemberDrawer();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ---- Bulk selection ----
|
|
627
|
+
function refreshBulk() {
|
|
628
|
+
var checks = document.querySelectorAll('#member-rows .row-check:checked');
|
|
629
|
+
var count = checks.length;
|
|
630
|
+
var bar = document.getElementById('bulk-bar');
|
|
631
|
+
var ids = document.getElementById('bulk-ids');
|
|
632
|
+
if (bar) bar.classList.toggle('active', count > 0);
|
|
633
|
+
var label = document.getElementById('bulk-count');
|
|
634
|
+
if (label) label.textContent = count + ' selected';
|
|
635
|
+
if (ids) {
|
|
636
|
+
ids.innerHTML = '';
|
|
637
|
+
checks.forEach(function (c) {
|
|
638
|
+
var inp = document.createElement('input');
|
|
639
|
+
inp.type = 'hidden'; inp.name = 'user_ids'; inp.value = c.value;
|
|
640
|
+
ids.appendChild(inp);
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
document.addEventListener('change', function (e) {
|
|
645
|
+
if (e.target.classList && e.target.classList.contains('row-check')) refreshBulk();
|
|
646
|
+
if (e.target.classList && e.target.classList.contains('row-check-all')) {
|
|
647
|
+
var checked = e.target.checked;
|
|
648
|
+
document.querySelectorAll('#member-rows .row-check').forEach(function (c) { c.checked = checked; });
|
|
649
|
+
refreshBulk();
|
|
650
|
+
}
|
|
651
|
+
// Show role selector when bulk action = set_role
|
|
652
|
+
if (e.target && e.target.name === 'action') {
|
|
653
|
+
var roleSel = document.getElementById('bulk-role');
|
|
654
|
+
if (roleSel) roleSel.style.display = (e.target.value === 'set_role') ? 'inline-block' : 'none';
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
// Re-bind bulk after htmx swaps the table
|
|
658
|
+
document.body.addEventListener('htmx:afterSwap', function () { refreshBulk(); });
|
|
659
|
+
|
|
660
|
+
// ---- Security Pre-Check & Confirmation Flow ----
|
|
661
|
+
var currentStagingId = null;
|
|
662
|
+
var currentAppName = null;
|
|
663
|
+
var currentAppSlug = null;
|
|
664
|
+
var currentAppEntry = null;
|
|
665
|
+
|
|
666
|
+
function openSecurityLoading(title, msg) {
|
|
667
|
+
document.getElementById('security-loading-title').textContent = title || 'Security Pre-Check in Progress';
|
|
668
|
+
document.getElementById('security-loading-msg').textContent = msg || 'Staging package & running static AST security audit...';
|
|
669
|
+
document.getElementById('security-loading-modal').style.display = 'flex';
|
|
670
|
+
}
|
|
671
|
+
function closeSecurityLoading() {
|
|
672
|
+
document.getElementById('security-loading-modal').style.display = 'none';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function openSecurityReport(data) {
|
|
676
|
+
currentStagingId = data.staging_id;
|
|
677
|
+
currentAppName = data.name;
|
|
678
|
+
currentAppSlug = data.slug;
|
|
679
|
+
currentAppEntry = data.entry_point;
|
|
680
|
+
|
|
681
|
+
var report = data.report;
|
|
682
|
+
var badge = document.getElementById('sec-risk-badge');
|
|
683
|
+
badge.textContent = report.risk_level;
|
|
684
|
+
badge.className = 'badge ' + (
|
|
685
|
+
report.risk_level === 'CLEAN' ? 'badge-success' :
|
|
686
|
+
report.risk_level === 'LOW' ? 'badge-info' :
|
|
687
|
+
report.risk_level === 'MEDIUM' ? 'badge-warning' : 'badge-danger'
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
document.getElementById('sec-app-info').textContent = data.name + ' (slug: ' + data.slug + ', entrypoint: ' + (data.entry_point || 'app:app') + ')';
|
|
691
|
+
document.getElementById('sec-stat-files').textContent = report.files_scanned || 0;
|
|
692
|
+
document.getElementById('sec-stat-py').textContent = report.py_files_scanned || 0;
|
|
693
|
+
document.getElementById('sec-stat-findings').textContent = (report.findings && report.findings.length) ? report.findings.length : 0;
|
|
694
|
+
|
|
695
|
+
var banner = document.getElementById('sec-summary-banner');
|
|
696
|
+
banner.className = 'alert ' + (report.is_safe ? (report.findings && report.findings.length ? 'alert-info' : 'alert-success') : 'alert-danger');
|
|
697
|
+
banner.textContent = report.summary || 'Security scan completed.';
|
|
698
|
+
|
|
699
|
+
var container = document.getElementById('sec-findings-container');
|
|
700
|
+
container.innerHTML = '';
|
|
701
|
+
|
|
702
|
+
if (!report.findings || report.findings.length === 0) {
|
|
703
|
+
container.innerHTML = '<div class="text-muted mono p-3 mb-3" style="background:var(--surface-2); border-radius:var(--radius-sm); border:1px solid var(--border); font-size:0.85rem;">✓ No suspicious calls, command injections, or dangerous dependencies detected. Clean static AST audit.</div>';
|
|
704
|
+
} else {
|
|
705
|
+
report.findings.forEach(function(f) {
|
|
706
|
+
var card = document.createElement('div');
|
|
707
|
+
card.className = 'finding-card severity-' + (f.severity || 'INFO');
|
|
708
|
+
|
|
709
|
+
var head = document.createElement('div');
|
|
710
|
+
head.className = 'finding-header';
|
|
711
|
+
|
|
712
|
+
var title = document.createElement('span');
|
|
713
|
+
title.className = 'finding-title';
|
|
714
|
+
title.textContent = (f.category || 'Security Issue') + ' — ' + f.message;
|
|
715
|
+
|
|
716
|
+
var sevBadge = document.createElement('span');
|
|
717
|
+
sevBadge.className = 'badge ' + (f.severity === 'CRITICAL' ? 'badge-danger' : (f.severity === 'HIGH' ? 'badge-danger' : (f.severity === 'MEDIUM' ? 'badge-warning' : 'badge-info')));
|
|
718
|
+
sevBadge.textContent = f.severity;
|
|
719
|
+
|
|
720
|
+
head.appendChild(title);
|
|
721
|
+
head.appendChild(sevBadge);
|
|
722
|
+
card.appendChild(head);
|
|
723
|
+
|
|
724
|
+
if (f.file || f.line) {
|
|
725
|
+
var meta = document.createElement('div');
|
|
726
|
+
meta.className = 'finding-meta';
|
|
727
|
+
meta.textContent = (f.file || 'unknown') + (f.line ? (':' + f.line) : '');
|
|
728
|
+
card.appendChild(meta);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (f.snippet) {
|
|
732
|
+
var snippet = document.createElement('div');
|
|
733
|
+
snippet.className = 'finding-snippet';
|
|
734
|
+
snippet.textContent = f.snippet;
|
|
735
|
+
card.appendChild(snippet);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
container.appendChild(card);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
var confirmBtn = document.getElementById('btn-confirm-security');
|
|
743
|
+
confirmBtn.disabled = false;
|
|
744
|
+
confirmBtn.textContent = 'Proceed & Install Application';
|
|
745
|
+
if (report.risk_level === 'CRITICAL' || report.risk_level === 'HIGH') {
|
|
746
|
+
confirmBtn.className = 'btn btn-danger';
|
|
747
|
+
} else {
|
|
748
|
+
confirmBtn.className = 'btn btn-primary';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
document.getElementById('security-modal').style.display = 'flex';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function closeSecurityReport() {
|
|
755
|
+
document.getElementById('security-modal').style.display = 'none';
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
var cancelSecBtn = document.getElementById('btn-cancel-security');
|
|
759
|
+
if (cancelSecBtn) {
|
|
760
|
+
cancelSecBtn.addEventListener('click', function() {
|
|
761
|
+
if (currentStagingId) {
|
|
762
|
+
fetch('/admin/apps/cancel-staged', {
|
|
763
|
+
method: 'POST',
|
|
764
|
+
headers: { 'Content-Type': 'application/json' },
|
|
765
|
+
body: JSON.stringify({ staging_id: currentStagingId })
|
|
766
|
+
}).catch(function() {});
|
|
767
|
+
currentStagingId = null;
|
|
768
|
+
}
|
|
769
|
+
closeSecurityReport();
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
var confirmSecBtn = document.getElementById('btn-confirm-security');
|
|
774
|
+
if (confirmSecBtn) {
|
|
775
|
+
confirmSecBtn.addEventListener('click', function() {
|
|
776
|
+
if (!currentStagingId) return;
|
|
777
|
+
var btn = this;
|
|
778
|
+
btn.disabled = true;
|
|
779
|
+
btn.textContent = 'Installing & Initializing Sub-App...';
|
|
780
|
+
|
|
781
|
+
fetch('/admin/apps/install-confirm', {
|
|
782
|
+
method: 'POST',
|
|
783
|
+
headers: { 'Content-Type': 'application/json' },
|
|
784
|
+
body: JSON.stringify({
|
|
785
|
+
staging_id: currentStagingId,
|
|
786
|
+
name: currentAppName,
|
|
787
|
+
slug: currentAppSlug,
|
|
788
|
+
entry_point: currentAppEntry
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
.then(function(r) {
|
|
792
|
+
return r.json().catch(function() { return { success: false, error: 'Server returned HTTP ' + r.status }; });
|
|
793
|
+
})
|
|
794
|
+
.then(function(res) {
|
|
795
|
+
if (res && res.success) {
|
|
796
|
+
window.location.href = '/admin';
|
|
797
|
+
} else {
|
|
798
|
+
alert('Installation failed: ' + ((res && res.error) || 'Unknown error'));
|
|
799
|
+
btn.disabled = false;
|
|
800
|
+
btn.textContent = 'Proceed & Install Application';
|
|
801
|
+
}
|
|
802
|
+
})
|
|
803
|
+
.catch(function(err) {
|
|
804
|
+
alert('Network error during installation: ' + err.message);
|
|
805
|
+
btn.disabled = false;
|
|
806
|
+
btn.textContent = 'Proceed & Install Application';
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Intercept Git Install form
|
|
812
|
+
var gitForm = document.getElementById('form-install-git');
|
|
813
|
+
if (gitForm) {
|
|
814
|
+
gitForm.addEventListener('submit', function(e) {
|
|
815
|
+
e.preventDefault();
|
|
816
|
+
var formData = new FormData(gitForm);
|
|
817
|
+
openSecurityLoading('Cloning & Analyzing Repository', 'Pulling git repository into isolated sandbox & executing static security analysis...');
|
|
818
|
+
|
|
819
|
+
fetch('/admin/apps/precheck-git', {
|
|
820
|
+
method: 'POST',
|
|
821
|
+
body: formData,
|
|
822
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
823
|
+
})
|
|
824
|
+
.then(function(r) {
|
|
825
|
+
return r.json().catch(function() {
|
|
826
|
+
return { success: false, error: 'Server returned status HTTP ' + r.status };
|
|
827
|
+
});
|
|
828
|
+
})
|
|
829
|
+
.then(function(res) {
|
|
830
|
+
closeSecurityLoading();
|
|
831
|
+
if (res && res.success) {
|
|
832
|
+
openSecurityReport(res);
|
|
833
|
+
} else {
|
|
834
|
+
alert('Pre-check failed: ' + ((res && res.error) || 'Unknown error'));
|
|
835
|
+
}
|
|
836
|
+
})
|
|
837
|
+
.catch(function(err) {
|
|
838
|
+
closeSecurityLoading();
|
|
839
|
+
alert('Request failed: ' + err.message);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Intercept ZIP Install form
|
|
845
|
+
var zipForm = document.getElementById('form-install-zip');
|
|
846
|
+
if (zipForm) {
|
|
847
|
+
zipForm.addEventListener('submit', function(e) {
|
|
848
|
+
e.preventDefault();
|
|
849
|
+
var formData = new FormData(zipForm);
|
|
850
|
+
openSecurityLoading('Extracting & Analyzing ZIP Package', 'Safely unpacking archive in sandbox & executing static security analysis...');
|
|
851
|
+
|
|
852
|
+
fetch('/admin/apps/precheck-zip', {
|
|
853
|
+
method: 'POST',
|
|
854
|
+
body: formData,
|
|
855
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
856
|
+
})
|
|
857
|
+
.then(function(r) {
|
|
858
|
+
return r.json().catch(function() {
|
|
859
|
+
return { success: false, error: 'Server returned status HTTP ' + r.status };
|
|
860
|
+
});
|
|
861
|
+
})
|
|
862
|
+
.then(function(res) {
|
|
863
|
+
closeSecurityLoading();
|
|
864
|
+
if (res && res.success) {
|
|
865
|
+
openSecurityReport(res);
|
|
866
|
+
} else {
|
|
867
|
+
alert('Pre-check failed: ' + ((res && res.error) || 'Unknown error'));
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
.catch(function(err) {
|
|
871
|
+
closeSecurityLoading();
|
|
872
|
+
alert('Request failed: ' + err.message);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Auto-populate App Name and Slug from selected zip file name
|
|
878
|
+
var zipInput = document.getElementById('zip_file_input');
|
|
879
|
+
if (zipInput) {
|
|
880
|
+
zipInput.addEventListener('change', function() {
|
|
881
|
+
if (this.files && this.files.length > 0) {
|
|
882
|
+
var rawName = this.files[0].name.replace(/\.zip$/i, '');
|
|
883
|
+
var nameField = document.getElementById('zip_app_name');
|
|
884
|
+
var slugField = document.getElementById('zip_app_slug');
|
|
885
|
+
if (nameField && !nameField.value.trim()) {
|
|
886
|
+
var formatted = rawName.replace(/[-_]/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); });
|
|
887
|
+
nameField.value = formatted;
|
|
888
|
+
}
|
|
889
|
+
if (slugField && !slugField.value.trim()) {
|
|
890
|
+
slugField.value = rawName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Activate members tab when deep-linked via /admin/members
|
|
897
|
+
(function () {
|
|
898
|
+
var active = '{{ active_tab or "" }}';
|
|
899
|
+
if (active && document.getElementById('panel-' + active)) activateTab(active);
|
|
900
|
+
})();
|
|
901
|
+
</script>
|
|
902
|
+
{% endblock %}
|