Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_team.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_team.cgi

added on local at 2026-07-11 18:36:59

Added
+443
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 3548f147d85d
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# ABForge - Admin Team Members
4#
5# Any active admin uses this page to add, list, role-edit, and remove
6# fellow platform admins. All admins share one logical team (no
7# sub-admin hierarchy); the only differentiator is which permission
8# group (Manager / Editor / Viewer) gates their access to admin
9# features at template render time.
10#
11# Visibility: admin-only via MODS::ABForge::Admin->require_admin. Non-
12# admin sellers never reach here -- the sidebar entry is admin-only.
13#
14# Self-protection: an admin cannot demote or remove themselves through
15# this page (avoids the "last manager locked out" footgun on a small
16# admin team). They can still adjust their own role via direct SQL or
17# by having another admin update it.
18#======================================================================
19use strict;
20use warnings;
21
22use lib '/var/www/vhosts/abforge.com/httpdocs';
23use CGI;
24use MODS::Template;
25use MODS::DBConnect;
26use MODS::Login;
27use MODS::ABForge::Config;
28use MODS::ABForge::Wrapper;
29use MODS::ABForge::Admin;
30
31my $q = CGI->new;
32my $form = $q->Vars;
33my $auth = MODS::Login->new;
34my $wrap = MODS::ABForge::Wrapper->new;
35my $tfile = MODS::Template->new;
36my $db = MODS::DBConnect->new;
37my $cfg = MODS::ABForge::Config->new;
38my $admin = MODS::ABForge::Admin->new;
39my $DB = $cfg->settings('database_name');
40
41$|=1;
42my $userinfo = $auth->login_verify();
43if (!$userinfo) {
44 print "Status: 302 Found\nLocation: /login.cgi\n\n";
45 exit;
46}
47$admin->require_admin($userinfo);
48
49# Resolve whether the *viewer* is super-admin. login_verify doesn't
50# populate users.is_super_admin into the session, so go through the
51# Permissions helper which probes lazily and caches.
52require MODS::ABForge::Permissions;
53my $perm = MODS::ABForge::Permissions->new;
54my $viewer_is_super = $perm->is_super_admin($userinfo) ? 1 : 0;
55
56my $me_uid = $userinfo->{user_id};
57$me_uid =~ s/[^0-9]//g;
58
59sub _esc {
60 my $s = shift;
61 $s //= '';
62 $s =~ s/\\/\\\\/g;
63 $s =~ s/'/''/g;
64 return $s;
65}
66
67sub _h {
68 my $s = shift;
69 $s //= '';
70 $s =~ s/&/&/g;
71 $s =~ s/</&lt;/g;
72 $s =~ s/>/&gt;/g;
73 $s =~ s/"/&quot;/g;
74 return $s;
75}
76
77# ---- Viewer-local timestamp wrapper ---------------------------------
78sub _atm_ts_wrap {
79 my ($human, $epoch, $fmt) = @_;
80 $fmt = 'datetime' unless defined $fmt && length $fmt;
81 return '-' unless defined($human) && length($human);
82 my $esc = _h($human);
83 return $esc unless $epoch && $epoch =~ /^\d+$/;
84 return qq~<span class="ts" data-ts="$epoch" data-fmt="$fmt">$esc</span>~;
85}
86
87my $dbh = $db->db_connect();
88
89# ---- Guard: permission_groups must exist before we query it ---------
90my $have_pg_tbl = $db->db_readwrite($dbh, qq~
91 SELECT COUNT(*) AS n FROM information_schema.tables
92 WHERE table_schema='$DB' AND table_name='permission_groups'
93~, $ENV{SCRIPT_NAME}, __LINE__);
94my $perm_ready = ($have_pg_tbl && $have_pg_tbl->{n}) ? 1 : 0;
95
96my $flash_msg = '';
97my $flash_kind = '';
98
99my $action = $form->{action} || '';
100
101if ($action eq 'add_admin' && $perm_ready) {
102 my $email = $form->{email} // '';
103 my $name = $form->{display_name} // '';
104 my $pw = $form->{password} // '';
105 my $role_id = $form->{permission_groups_id} // '';
106
107 $email =~ s/^\s+|\s+$//g;
108 $name =~ s/^\s+|\s+$//g;
109 $role_id =~ s/[^0-9]//g;
110
111 if ($email !~ /\@/ || $name eq '' || length($pw) < 8 || !$role_id) {
112 $flash_msg = 'Email, display name, role, and a password (8+ chars) are required.';
113 $flash_kind = 'error';
114 } else {
115 my $existing = $db->db_readwrite($dbh, qq~
116 SELECT id FROM `${DB}`.users WHERE email='~ . _esc($email) . qq~' LIMIT 1
117 ~, $ENV{SCRIPT_NAME}, __LINE__);
118
119 if ($existing && $existing->{id}) {
120 $flash_msg = 'An account with that email already exists.';
121 $flash_kind = 'error';
122 } else {
123 my $role_ok = $db->db_readwrite($dbh, qq~
124 SELECT id FROM `${DB}`.permission_groups
125 WHERE id='$role_id' AND scope='admin'
126 AND group_status=1
127 AND owner_user_id IS NULL
128 LIMIT 1
129 ~, $ENV{SCRIPT_NAME}, __LINE__);
130
131 if (!$role_ok || !$role_ok->{id}) {
132 $flash_msg = 'Pick a valid admin role.';
133 $flash_kind = 'error';
134 } else {
135 my $new_id = $auth->signup($email, $pw, $name, 'free');
136 if (!$new_id) {
137 $flash_msg = 'Could not create the user (password may be too weak or email rejected).';
138 $flash_kind = 'error';
139 } else {
140 # Mark them admin and attach role. No owner_user_id
141 # for admins -- they're peers, not sub-accounts.
142 $db->db_readwrite($dbh, qq~
143 UPDATE `${DB}`.users
144 SET is_admin=1,
145 permission_groups_id='$role_id'
146 WHERE id='$new_id'
147 ~, $ENV{SCRIPT_NAME}, __LINE__);
148 $flash_msg = "Added admin $email.";
149 $flash_kind = 'success';
150 }
151 }
152 }
153 }
154}
155elsif ($action eq 'update_role' && $perm_ready) {
156 my $member_id = $form->{member_id} // '';
157 my $role_id = $form->{permission_groups_id} // '';
158 $member_id =~ s/[^0-9]//g;
159 $role_id =~ s/[^0-9]//g;
160
161 if ($member_id eq $me_uid) {
162 $flash_msg = "You can't change your own admin role from here.";
163 $flash_kind = 'error';
164 } elsif ($member_id && $role_id) {
165 my $target_ok = $db->db_readwrite($dbh, qq~
166 SELECT id FROM `${DB}`.users
167 WHERE id='$member_id' AND is_admin=1 LIMIT 1
168 ~, $ENV{SCRIPT_NAME}, __LINE__);
169 my $role_ok = $db->db_readwrite($dbh, qq~
170 SELECT id FROM `${DB}`.permission_groups
171 WHERE id='$role_id' AND scope='admin' AND group_status=1 AND owner_user_id IS NULL LIMIT 1
172 ~, $ENV{SCRIPT_NAME}, __LINE__);
173 if ($target_ok && $target_ok->{id} && $role_ok && $role_ok->{id}) {
174 $db->db_readwrite($dbh, qq~
175 UPDATE `${DB}`.users SET permission_groups_id='$role_id' WHERE id='$member_id'
176 ~, $ENV{SCRIPT_NAME}, __LINE__);
177 $flash_msg = 'Role updated.';
178 $flash_kind = 'success';
179 } else {
180 $flash_msg = 'Could not update that admin.';
181 $flash_kind = 'error';
182 }
183 }
184}
185elsif ($action eq 'toggle_super_admin' && $viewer_is_super) {
186 # Promote or revoke super-admin on another admin. Guards:
187 # - self-toggle blocked (you can't demote yourself; would leave no
188 # super-admin if you're the only one)
189 # - target must already be an admin
190 # - last-super-admin guard: demote refused if it would leave zero
191 my $member_id = $form->{member_id} // '';
192 $member_id =~ s/[^0-9]//g;
193 my $want = ($form->{want} && $form->{want} eq '1') ? 1 : 0;
194
195 if (!$member_id || $member_id eq $me_uid) {
196 $flash_msg = "You can't change your own super-admin status from here.";
197 $flash_kind = 'error';
198 } else {
199 my $target = $db->db_readwrite($dbh, qq~
200 SELECT id, is_super_admin FROM `${DB}`.users
201 WHERE id='$member_id' AND is_admin=1 LIMIT 1
202 ~, $ENV{SCRIPT_NAME}, __LINE__);
203 if (!$target || !$target->{id}) {
204 $flash_msg = 'Target admin not found.';
205 $flash_kind = 'error';
206 } elsif (!$want) {
207 # Refuse the demote if it would leave zero super-admins.
208 my $count = $db->db_readwrite($dbh, qq~
209 SELECT COUNT(*) AS n FROM `${DB}`.users
210 WHERE is_super_admin=1 AND id != '$member_id'
211 ~, $ENV{SCRIPT_NAME}, __LINE__);
212 if (!$count || !$count->{n}) {
213 $flash_msg = "Can't demote the last super-admin.";
214 $flash_kind = 'error';
215 } else {
216 $db->db_readwrite($dbh, qq~
217 UPDATE `${DB}`.users
218 SET is_super_admin=0, updated_at=NOW()
219 WHERE id='$member_id'
220 ~, $ENV{SCRIPT_NAME}, __LINE__);
221 $flash_msg = 'Super-admin removed.';
222 $flash_kind = 'success';
223 }
224 } else {
225 $db->db_readwrite($dbh, qq~
226 UPDATE `${DB}`.users
227 SET is_super_admin=1, is_admin=1, updated_at=NOW()
228 WHERE id='$member_id'
229 ~, $ENV{SCRIPT_NAME}, __LINE__);
230 $flash_msg = 'Promoted to super-admin.';
231 $flash_kind = 'success';
232 }
233 }
234}
235elsif ($action eq 'remove_admin') {
236 my $member_id = $form->{member_id} // '';
237 $member_id =~ s/[^0-9]//g;
238
239 if ($member_id eq $me_uid) {
240 $flash_msg = "You can't remove yourself.";
241 $flash_kind = 'error';
242 } elsif ($member_id) {
243 # Belt-and-suspenders: only demote admins, never sellers.
244 my $target_ok = $db->db_readwrite($dbh, qq~
245 SELECT id FROM `${DB}`.users
246 WHERE id='$member_id' AND is_admin=1 LIMIT 1
247 ~, $ENV{SCRIPT_NAME}, __LINE__);
248 if ($target_ok && $target_ok->{id}) {
249 # Two-step: revoke admin + null permission group, then
250 # close the account. Keeps the audit trail intact (we
251 # don't blow the user row away) but blocks future admin
252 # access.
253 $db->db_readwrite($dbh, qq~
254 UPDATE `${DB}`.users
255 SET is_admin=0,
256 permission_groups_id=NULL,
257 account_status='closed'
258 WHERE id='$member_id'
259 ~, $ENV{SCRIPT_NAME}, __LINE__);
260 # Revoke any live sessions so they're kicked immediately.
261 $db->db_readwrite($dbh, qq~
262 UPDATE `${DB}`.user_sessions SET revoked_at=NOW()
263 WHERE user_id='$member_id' AND revoked_at IS NULL
264 ~, $ENV{SCRIPT_NAME}, __LINE__);
265 $flash_msg = 'Admin removed.';
266 $flash_kind = 'success';
267 }
268 }
269}
270
271# ---- Load admin roster ----------------------------------------------
272my @members;
273@members = $db->db_readwrite_multiple($dbh, qq~
274 SELECT u.id, u.email, u.display_name, u.last_login_at,
275 u.permission_groups_id,
276 u.is_super_admin,
277 COALESCE(pg.name, 'Unconstrained') AS role_name,
278 DATE_FORMAT(u.created_at, '%b %e, %Y') AS added_on,
279 DATE_FORMAT(u.last_login_at, '%b %e, %Y') AS last_seen,
280 UNIX_TIMESTAMP(u.created_at) AS added_on_epoch,
281 UNIX_TIMESTAMP(u.last_login_at) AS last_seen_epoch
282 FROM `${DB}`.users u
283 LEFT JOIN `${DB}`.permission_groups pg ON pg.id = u.permission_groups_id
284 WHERE u.is_admin=1 AND u.account_status='active'
285 ORDER BY u.created_at ASC
286~, $ENV{SCRIPT_NAME}, __LINE__) if $perm_ready;
287
288# Fallback when permission_groups doesn't exist yet -- still list the
289# active admins so the page renders something usable on the first deploy.
290if (!$perm_ready) {
291 @members = $db->db_readwrite_multiple($dbh, qq~
292 SELECT id, email, display_name, last_login_at,
293 is_super_admin,
294 DATE_FORMAT(created_at, '%b %e, %Y') AS added_on,
295 DATE_FORMAT(last_login_at, '%b %e, %Y') AS last_seen,
296 UNIX_TIMESTAMP(created_at) AS added_on_epoch,
297 UNIX_TIMESTAMP(last_login_at) AS last_seen_epoch
298 FROM `${DB}`.users
299 WHERE is_admin=1 AND account_status='active'
300 ORDER BY created_at ASC
301 ~, $ENV{SCRIPT_NAME}, __LINE__);
302}
303
304# ---- Load admin roles -----------------------------------------------
305my @roles;
306if ($perm_ready) {
307 @roles = $db->db_readwrite_multiple($dbh, qq~
308 SELECT id, name, system_slug
309 FROM `${DB}`.permission_groups
310 WHERE scope='admin' AND group_status=1 AND owner_user_id IS NULL
311 ORDER BY name ASC
312 ~, $ENV{SCRIPT_NAME}, __LINE__);
313}
314
315# ---- Role -> feature labels for the "What each role can do" panel
316my @role_features;
317if ($perm_ready) {
318 @role_features = $db->db_readwrite_multiple($dbh, qq~
319 SELECT pg.id AS role_id, pg.name AS role_name,
320 pf.label AS feature_label, pf.sort_order
321 FROM `${DB}`.permission_groups pg
322 JOIN `${DB}`.permission_features pf
323 ON FIND_IN_SET(pf.id, pg.permission_features_ids) > 0
324 WHERE pg.scope='admin'
325 AND pg.group_status=1
326 AND pg.owner_user_id IS NULL
327 ORDER BY pg.name ASC, pf.sort_order ASC
328 ~, $ENV{SCRIPT_NAME}, __LINE__);
329}
330
331$db->db_disconnect($dbh);
332
333# ---- Build template vars --------------------------------------------
334my @member_rows;
335foreach my $m (@members) {
336 my @role_opts;
337 foreach my $r (@roles) {
338 push @role_opts, {
339 value => $r->{id},
340 label => _h($r->{name}),
341 selected_attr => ($r->{id} eq ($m->{permission_groups_id} || '') ? ' selected' : ''),
342 };
343 }
344 my $is_self = ($m->{id} eq $me_uid) ? 1 : 0;
345 my $is_super = $m->{is_super_admin} ? 1 : 0;
346 # TF-parity decoration
347 my @_palette_ = ('#5aa9ff','#34d399','#fbbf24','#a78bfa','#f87171','#22c55e','#06b6d4','#ec4899','#f59e0b');
348 my $_av_ = $_palette_[ ($m->{id} || 0) % scalar(@_palette_) ];
349 my $_dn_ = $m->{display_name} || $m->{email} || '?';
350 my @_p_ = split /\s+/, $_dn_, 2;
351 my $_ini_ = uc(substr($_p_[0]||'',0,1)) . uc(substr($_p_[1]||'',0,1));
352 $_ini_ = uc(substr($_dn_,0,2)) unless length $_ini_;
353 push @member_rows, {
354 avatar_color => $_av_,
355 initials => $_ini_,
356 id => $m->{id},
357 email => _h($m->{email}),
358 display_name => _h($m->{display_name}),
359 role_name => _h($m->{role_name} || 'Unconstrained'),
360 added_on => _atm_ts_wrap($m->{added_on}, $m->{added_on_epoch}, 'date'),
361 last_seen => ($m->{last_seen}
362 ? _atm_ts_wrap($m->{last_seen}, $m->{last_seen_epoch}, 'datetime')
363 : 'Never'),
364 is_self => $is_self,
365 is_super_admin => $is_super,
366 not_super_admin => $is_super ? 0 : 1,
367 role_options => \@role_opts,
368 # Only render the super-admin toggle on rows other than the
369 # viewer's own (and only if the viewer can flip the bit at all).
370 # The action handler enforces this too.
371 show_super_toggle => ($viewer_is_super && !$is_self) ? 1 : 0,
372 };
373}
374
375my @add_role_opts;
376foreach my $r (@roles) {
377 push @add_role_opts, {
378 value => $r->{id},
379 label => _h($r->{name}),
380 };
381}
382
383# Collapse the flat role-feature join into role-grouped cards.
384my @role_cards;
385my %role_idx;
386foreach my $rf (@role_features) {
387 my $rid = $rf->{role_id};
388 if (!exists $role_idx{$rid}) {
389 push @role_cards, {
390 name => _h($rf->{role_name}),
391 features => [],
392 is_super => 0,
393 };
394 $role_idx{$rid} = $#role_cards;
395 }
396 push @{ $role_cards[ $role_idx{$rid} ]{features} }, { label => _h($rf->{feature_label}) };
397}
398
399# Append a synthetic Super Admin card. This role isn't backed by a
400# permission_groups row -- it's the users.is_super_admin flag -- so
401# its capabilities are hardcoded here. Listed last so the implicit
402# ordering reads "weakest to strongest": Viewer -> Editor -> Manager
403# -> Super Admin.
404push @role_cards, {
405 name => 'Super Admin',
406 is_super => 1,
407 features => [
408 { label => 'Everything in Manager' },
409 { label => 'View platform-wide revenue, MRR / ARR, signups' },
410 { label => 'Edit plan pricing &amp; packages' },
411 { label => 'Manage sandbox test credit cards' },
412 { label => 'Edit Software Configuration (Stripe keys, branding)' },
413 { label => 'Promote / demote other super admins' },
414 ],
415};
416
417my $tvars = {
418 perm_ready => $perm_ready,
419 has_perm_ready => $perm_ready ? 1 : 0,
420 flash_msg => _h($flash_msg),
421 has_flash => $flash_msg ? 1 : 0,
422 flash_is_error => ($flash_kind eq 'error') ? 1 : 0,
423 flash_is_success => ($flash_kind eq 'success') ? 1 : 0,
424 members => \@member_rows,
425 has_members => scalar(@member_rows) ? 1 : 0,
426 member_count => scalar(@member_rows),
427 add_role_options => \@add_role_opts,
428 has_roles => scalar(@add_role_opts) ? 1 : 0,
429 role_cards => \@role_cards,
430 has_role_cards => scalar(@role_cards) ? 1 : 0,
431 viewer_is_super_admin => $viewer_is_super,
432};
433
434print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
435
436my $body = join('', $tfile->template('abforge_admin_team.html', $tvars, $userinfo));
437
438$wrap->render({
439 userinfo => $userinfo,
440 page_key => 'admin_team',
441 title => 'Admin Team',
442 body => $body,
443});