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