Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/team.cgi
Diff
/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/team.cgi
added on local at 2026-07-01 21:47:20
Added
+557
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 40db79ba539d
to 40db79ba539d
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 | # RePricer - Team Members (seller side) | |
| 4 | # | |
| 5 | # Owners use this page to add, list, role-edit, and remove team members | |
| 6 | # under their account. Team members are full users in the `users` table | |
| 7 | # whose `owner_user_id` points to the owner -- when they log in their | |
| 8 | # session resolves to the owner's data scope (wired in Login.pm in the | |
| 9 | # follow-up phase). | |
| 10 | # | |
| 11 | # Visibility: | |
| 12 | # * Owner of own account -> sees + manages their team | |
| 13 | # * Already a team member -> redirected away (only owners manage teams) | |
| 14 | # * Not logged in -> redirected to /login.cgi | |
| 15 | # | |
| 16 | # Role assignment uses the permission_groups system. Owners pick from | |
| 17 | # the three baked-in seller roles (Manager / Editor / Viewer) or their | |
| 18 | # own custom groups (created right on this page via the "Roles" panel). | |
| 19 | # Built-in presets are read-only; only owner-authored groups can be | |
| 20 | # renamed, re-permissioned, or deleted. | |
| 21 | #====================================================================== | |
| 22 | use strict; | |
| 23 | use warnings; | |
| 24 | ||
| 25 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 26 | use CGI; | |
| 27 | use MODS::Template; | |
| 28 | use MODS::DBConnect; | |
| 29 | use MODS::Login; | |
| 30 | use MODS::RePricer::Config; | |
| 31 | use MODS::RePricer::Wrapper; | |
| 32 | ||
| 33 | my $q = CGI->new; | |
| 34 | my $form = $q->Vars; | |
| 35 | my $auth = MODS::Login->new; | |
| 36 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 37 | my $tfile = MODS::Template->new; | |
| 38 | my $db = MODS::DBConnect->new; | |
| 39 | my $cfg = MODS::RePricer::Config->new; | |
| 40 | my $DB = $cfg->settings('database_name'); | |
| 41 | ||
| 42 | $|=1; | |
| 43 | my $userinfo = $auth->login_verify(); | |
| 44 | if (!$userinfo) { | |
| 45 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 46 | exit; | |
| 47 | } | |
| 48 | my $uid = $userinfo->{user_id}; | |
| 49 | $uid =~ s/[^0-9]//g; | |
| 50 | ||
| 51 | # Resolve the effective owner id. Until the Phase-2 login wiring lands, | |
| 52 | # userinfo->{owner_user_id} won't be populated -- so we look it up here | |
| 53 | # directly off the users row. A row with owner_user_id NULL means the | |
| 54 | # current user IS the owner; anything else means they're a team member | |
| 55 | # and should not be on this page. | |
| 56 | # | |
| 57 | # Before the migration runs the owner_user_id column doesn't exist. We | |
| 58 | # probe information_schema first; if absent the page degrades to a | |
| 59 | # "migration needed" banner instead of crashing on the missing column. | |
| 60 | my $dbh = $db->db_connect(); | |
| 61 | ||
| 62 | my $have_owner_col = $db->db_readwrite($dbh, qq~ | |
| 63 | SELECT COUNT(*) AS n FROM information_schema.columns | |
| 64 | WHERE table_schema='$DB' AND table_name='users' AND column_name='owner_user_id' | |
| 65 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 66 | my $schema_ready = ($have_owner_col && $have_owner_col->{n}) ? 1 : 0; | |
| 67 | ||
| 68 | my $me; | |
| 69 | if ($schema_ready) { | |
| 70 | $me = $db->db_readwrite($dbh, qq~ | |
| 71 | SELECT id, email, display_name, owner_user_id | |
| 72 | FROM `${DB}`.users | |
| 73 | WHERE id='$uid' | |
| 74 | ~, $ENV{SCRIPT_NAME}, __LINE__) || {}; | |
| 75 | } else { | |
| 76 | $me = $db->db_readwrite($dbh, qq~ | |
| 77 | SELECT id, email, display_name | |
| 78 | FROM `${DB}`.users | |
| 79 | WHERE id='$uid' | |
| 80 | ~, $ENV{SCRIPT_NAME}, __LINE__) || {}; | |
| 81 | } | |
| 82 | ||
| 83 | if ($schema_ready && $me->{owner_user_id}) { | |
| 84 | # Team members don't manage the team. Send them to settings. | |
| 85 | $db->db_disconnect($dbh); | |
| 86 | print "Status: 302 Found\nLocation: /profile.cgi\n\n"; | |
| 87 | exit; | |
| 88 | } | |
| 89 | ||
| 90 | # Owners (= account holders) manage their own roster. | |
| 91 | my $owner_uid = $uid; | |
| 92 | ||
| 93 | sub _esc { | |
| 94 | my $s = shift; | |
| 95 | $s //= ''; | |
| 96 | $s =~ s/\\/\\\\/g; | |
| 97 | $s =~ s/'/''/g; | |
| 98 | return $s; | |
| 99 | } | |
| 100 | ||
| 101 | sub _h { | |
| 102 | my $s = shift; | |
| 103 | $s //= ''; | |
| 104 | $s =~ s/&/&/g; | |
| 105 | $s =~ s/</</g; | |
| 106 | $s =~ s/>/>/g; | |
| 107 | $s =~ s/"/"/g; | |
| 108 | return $s; | |
| 109 | } | |
| 110 | ||
| 111 | # ---- Guard: permission_groups must exist before we query it ---------- | |
| 112 | # Per the install convention, schema migrations can lag the code upload | |
| 113 | # by a few minutes. DBConnect's error() exits hard on missing tables, so | |
| 114 | # probe information_schema first and degrade gracefully if not present. | |
| 115 | # Both the column probe above and this table probe must pass for the | |
| 116 | # page to do real work. | |
| 117 | my $have_pg_tbl = $db->db_readwrite($dbh, qq~ | |
| 118 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 119 | WHERE table_schema='$DB' AND table_name='permission_groups' | |
| 120 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 121 | my $perm_ready = ($schema_ready && $have_pg_tbl && $have_pg_tbl->{n}) ? 1 : 0; | |
| 122 | ||
| 123 | # ---- POST handlers -------------------------------------------------- | |
| 124 | my $flash_msg = ''; | |
| 125 | my $flash_kind = ''; # 'success' or 'error' | |
| 126 | ||
| 127 | my $action = $form->{action} || ''; | |
| 128 | ||
| 129 | if ($action eq 'add_member' && $perm_ready) { | |
| 130 | my $email = $form->{email} // ''; | |
| 131 | my $name = $form->{display_name} // ''; | |
| 132 | my $pw = $form->{password} // ''; | |
| 133 | my $role_id = $form->{permission_groups_id} // ''; | |
| 134 | ||
| 135 | $email =~ s/^\s+|\s+$//g; | |
| 136 | $name =~ s/^\s+|\s+$//g; | |
| 137 | $role_id =~ s/[^0-9]//g; | |
| 138 | ||
| 139 | if ($email !~ /\@/ || $name eq '' || length($pw) < 8 || !$role_id) { | |
| 140 | $flash_msg = 'Email, display name, role, and a password (8+ chars) are required.'; | |
| 141 | $flash_kind = 'error'; | |
| 142 | } else { | |
| 143 | # Make sure email isn't already taken | |
| 144 | my $existing = $db->db_readwrite($dbh, qq~ | |
| 145 | SELECT id FROM `${DB}`.users WHERE email='~ . _esc($email) . qq~' LIMIT 1 | |
| 146 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 147 | ||
| 148 | if ($existing && $existing->{id}) { | |
| 149 | $flash_msg = "An account with that email already exists."; | |
| 150 | $flash_kind = 'error'; | |
| 151 | } else { | |
| 152 | # Validate role belongs to seller scope and is either system | |
| 153 | # or owned by this owner. | |
| 154 | my $role_ok = $db->db_readwrite($dbh, qq~ | |
| 155 | SELECT id FROM `${DB}`.permission_groups | |
| 156 | WHERE id='$role_id' | |
| 157 | AND scope='seller' | |
| 158 | AND group_status=1 | |
| 159 | AND (owner_user_id IS NULL OR owner_user_id='$owner_uid') | |
| 160 | LIMIT 1 | |
| 161 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 162 | ||
| 163 | if (!$role_ok || !$role_ok->{id}) { | |
| 164 | $flash_msg = 'Pick a valid role.'; | |
| 165 | $flash_kind = 'error'; | |
| 166 | } else { | |
| 167 | my $new_id = $auth->signup($email, $pw, $name, 'free'); | |
| 168 | if (!$new_id) { | |
| 169 | $flash_msg = 'Could not create the user (password may be too weak or email rejected).'; | |
| 170 | $flash_kind = 'error'; | |
| 171 | } else { | |
| 172 | # Attach team-membership: owner pointer + role group. | |
| 173 | $db->db_readwrite($dbh, qq~ | |
| 174 | UPDATE `${DB}`.users | |
| 175 | SET owner_user_id='$owner_uid', | |
| 176 | permission_groups_id='$role_id' | |
| 177 | WHERE id='$new_id' | |
| 178 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 179 | $flash_msg = "Added team member $email."; | |
| 180 | $flash_kind = 'success'; | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | elsif ($action eq 'update_role' && $perm_ready) { | |
| 187 | my $member_id = $form->{member_id} // ''; | |
| 188 | my $role_id = $form->{permission_groups_id} // ''; | |
| 189 | $member_id =~ s/[^0-9]//g; | |
| 190 | $role_id =~ s/[^0-9]//g; | |
| 191 | ||
| 192 | if ($member_id && $role_id) { | |
| 193 | # Confirm the target is one of ours, and the role is valid for us. | |
| 194 | my $owns = $db->db_readwrite($dbh, qq~ | |
| 195 | SELECT id FROM `${DB}`.users | |
| 196 | WHERE id='$member_id' AND owner_user_id='$owner_uid' LIMIT 1 | |
| 197 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 198 | my $role_ok = $db->db_readwrite($dbh, qq~ | |
| 199 | SELECT id FROM `${DB}`.permission_groups | |
| 200 | WHERE id='$role_id' | |
| 201 | AND scope='seller' | |
| 202 | AND group_status=1 | |
| 203 | AND (owner_user_id IS NULL OR owner_user_id='$owner_uid') | |
| 204 | LIMIT 1 | |
| 205 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 206 | ||
| 207 | if ($owns && $owns->{id} && $role_ok && $role_ok->{id}) { | |
| 208 | $db->db_readwrite($dbh, qq~ | |
| 209 | UPDATE `${DB}`.users SET permission_groups_id='$role_id' WHERE id='$member_id' | |
| 210 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 211 | $flash_msg = 'Role updated.'; | |
| 212 | $flash_kind = 'success'; | |
| 213 | } else { | |
| 214 | $flash_msg = 'Could not update that member.'; | |
| 215 | $flash_kind = 'error'; | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | elsif ($action eq 'create_role' && $perm_ready) { | |
| 220 | # Owner is creating a brand-new custom seller-scope role. Form | |
| 221 | # supplies: role_name + a multi-value feature_ids[] from a checkbox | |
| 222 | # grid. Empty feature set is allowed (the resulting role grants | |
| 223 | # nothing -- useful as a "no access yet" parking role). | |
| 224 | my $role_name = $form->{role_name} // ''; | |
| 225 | $role_name =~ s/^\s+|\s+$//g; | |
| 226 | my @raw_ids = $q->multi_param('feature_ids'); | |
| 227 | @raw_ids = grep { /^\d+$/ } @raw_ids; | |
| 228 | ||
| 229 | if (length($role_name) < 2 || length($role_name) > 120) { | |
| 230 | $flash_msg = 'Role name must be 2-120 characters.'; | |
| 231 | $flash_kind = 'error'; | |
| 232 | } else { | |
| 233 | # Filter the submitted feature ids against the real seller-scope | |
| 234 | # catalog so an attacker can't smuggle admin-scope feature ids in. | |
| 235 | my %allowed_ids; | |
| 236 | my @cat = $db->db_readwrite_multiple($dbh, qq~ | |
| 237 | SELECT id FROM `${DB}`.permission_features WHERE scope='seller' | |
| 238 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 239 | foreach my $f (@cat) { $allowed_ids{ $f->{id} } = 1; } | |
| 240 | my @clean = grep { $allowed_ids{$_} } @raw_ids; | |
| 241 | my %seen; @clean = grep { !$seen{$_}++ } @clean; # dedupe | |
| 242 | my $csv = join(',', sort { $a <=> $b } @clean); | |
| 243 | ||
| 244 | # Dupe-name guard: don't let the owner create two custom roles | |
| 245 | # with the same name -- the dropdown would become ambiguous. | |
| 246 | my $name_esc = _esc($role_name); | |
| 247 | my $dup = $db->db_readwrite($dbh, qq~ | |
| 248 | SELECT id FROM `${DB}`.permission_groups | |
| 249 | WHERE scope='seller' AND name='$name_esc' | |
| 250 | AND (owner_user_id='$owner_uid' OR owner_user_id IS NULL) | |
| 251 | AND group_status=1 | |
| 252 | LIMIT 1 | |
| 253 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 254 | if ($dup && $dup->{id}) { | |
| 255 | $flash_msg = "A role named '$role_name' already exists."; | |
| 256 | $flash_kind = 'error'; | |
| 257 | } else { | |
| 258 | $db->db_readwrite($dbh, qq~ | |
| 259 | INSERT INTO `${DB}`.permission_groups | |
| 260 | SET owner_user_id='$owner_uid', | |
| 261 | scope='seller', | |
| 262 | name='$name_esc', | |
| 263 | system_slug=NULL, | |
| 264 | permission_features_ids='$csv', | |
| 265 | group_status=1, | |
| 266 | created_at=NOW() | |
| 267 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 268 | $flash_msg = "Created role '$role_name'."; | |
| 269 | $flash_kind = 'success'; | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 273 | elsif ($action eq 'update_role_perms' && $perm_ready) { | |
| 274 | # Edit an existing CUSTOM role -- preset rows are blocked here by | |
| 275 | # the owner_user_id check. The form posts back the same role_id + | |
| 276 | # role_name + feature_ids[] structure as create_role. | |
| 277 | my $role_id = $form->{role_id} // ''; | |
| 278 | my $role_name = $form->{role_name} // ''; | |
| 279 | $role_id =~ s/[^0-9]//g; | |
| 280 | $role_name =~ s/^\s+|\s+$//g; | |
| 281 | my @raw_ids = $q->multi_param('feature_ids'); | |
| 282 | @raw_ids = grep { /^\d+$/ } @raw_ids; | |
| 283 | ||
| 284 | if (!$role_id || length($role_name) < 2 || length($role_name) > 120) { | |
| 285 | $flash_msg = 'Role name must be 2-120 characters.'; | |
| 286 | $flash_kind = 'error'; | |
| 287 | } else { | |
| 288 | # Confirm the role exists, is custom (owner_user_id matches the | |
| 289 | # current owner), and is in the seller scope. Preset rows have | |
| 290 | # owner_user_id IS NULL -- they are NOT editable here. | |
| 291 | my $own = $db->db_readwrite($dbh, qq~ | |
| 292 | SELECT id FROM `${DB}`.permission_groups | |
| 293 | WHERE id='$role_id' | |
| 294 | AND scope='seller' | |
| 295 | AND owner_user_id='$owner_uid' | |
| 296 | LIMIT 1 | |
| 297 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 298 | unless ($own && $own->{id}) { | |
| 299 | $flash_msg = 'You can only edit your own custom roles, not built-in presets.'; | |
| 300 | $flash_kind = 'error'; | |
| 301 | } else { | |
| 302 | my %allowed_ids; | |
| 303 | my @cat = $db->db_readwrite_multiple($dbh, qq~ | |
| 304 | SELECT id FROM `${DB}`.permission_features WHERE scope='seller' | |
| 305 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 306 | foreach my $f (@cat) { $allowed_ids{ $f->{id} } = 1; } | |
| 307 | my @clean = grep { $allowed_ids{$_} } @raw_ids; | |
| 308 | my %seen; @clean = grep { !$seen{$_}++ } @clean; | |
| 309 | my $csv = join(',', sort { $a <=> $b } @clean); | |
| 310 | my $name_esc = _esc($role_name); | |
| 311 | ||
| 312 | $db->db_readwrite($dbh, qq~ | |
| 313 | UPDATE `${DB}`.permission_groups | |
| 314 | SET name='$name_esc', | |
| 315 | permission_features_ids='$csv' | |
| 316 | WHERE id='$role_id' AND owner_user_id='$owner_uid' | |
| 317 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 318 | $flash_msg = "Updated role '$role_name'."; | |
| 319 | $flash_kind = 'success'; | |
| 320 | } | |
| 321 | } | |
| 322 | } | |
| 323 | elsif ($action eq 'delete_role' && $perm_ready) { | |
| 324 | # Soft-delete (group_status=0) instead of DELETE so historical | |
| 325 | # references on suspended/old team-member rows stay readable. Only | |
| 326 | # custom roles can be deleted; preset rows are protected via the | |
| 327 | # owner_user_id check. | |
| 328 | my $role_id = $form->{role_id} // ''; | |
| 329 | $role_id =~ s/[^0-9]//g; | |
| 330 | if ($role_id) { | |
| 331 | my $own = $db->db_readwrite($dbh, qq~ | |
| 332 | SELECT id FROM `${DB}`.permission_groups | |
| 333 | WHERE id='$role_id' | |
| 334 | AND scope='seller' | |
| 335 | AND owner_user_id='$owner_uid' | |
| 336 | LIMIT 1 | |
| 337 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 338 | unless ($own && $own->{id}) { | |
| 339 | $flash_msg = 'That is not a custom role you own.'; | |
| 340 | $flash_kind = 'error'; | |
| 341 | } else { | |
| 342 | # Refuse if any team member still uses it. Force the owner | |
| 343 | # to reassign first so we don't strand seats on an inactive | |
| 344 | # group (Permissions.pm gates inactive groups via | |
| 345 | # group_status=1, which would leave the member with zero | |
| 346 | # access until reassigned). | |
| 347 | my $in_use = $db->db_readwrite($dbh, qq~ | |
| 348 | SELECT COUNT(*) AS n FROM `${DB}`.users | |
| 349 | WHERE permission_groups_id='$role_id' | |
| 350 | AND owner_user_id='$owner_uid' | |
| 351 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 352 | if ($in_use && $in_use->{n}) { | |
| 353 | $flash_msg = 'Reassign the ' . $in_use->{n} . ' member(s) using this role to another role before deleting it.'; | |
| 354 | $flash_kind = 'error'; | |
| 355 | } else { | |
| 356 | $db->db_readwrite($dbh, qq~ | |
| 357 | UPDATE `${DB}`.permission_groups | |
| 358 | SET group_status=0 | |
| 359 | WHERE id='$role_id' AND owner_user_id='$owner_uid' | |
| 360 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 361 | $flash_msg = 'Role deleted.'; | |
| 362 | $flash_kind = 'success'; | |
| 363 | } | |
| 364 | } | |
| 365 | } | |
| 366 | } | |
| 367 | elsif ($action eq 'remove_member') { | |
| 368 | my $member_id = $form->{member_id} // ''; | |
| 369 | $member_id =~ s/[^0-9]//g; | |
| 370 | ||
| 371 | if ($member_id) { | |
| 372 | # Only remove rows we own. Cascade deletes user_sessions etc. | |
| 373 | my $owns = $db->db_readwrite($dbh, qq~ | |
| 374 | SELECT id FROM `${DB}`.users | |
| 375 | WHERE id='$member_id' AND owner_user_id='$owner_uid' LIMIT 1 | |
| 376 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 377 | if ($owns && $owns->{id}) { | |
| 378 | $db->db_readwrite($dbh, qq~ | |
| 379 | DELETE FROM `${DB}`.users WHERE id='$member_id' AND owner_user_id='$owner_uid' | |
| 380 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 381 | $flash_msg = 'Team member removed.'; | |
| 382 | $flash_kind = 'success'; | |
| 383 | } | |
| 384 | } | |
| 385 | } | |
| 386 | ||
| 387 | # ---- Load team roster ----------------------------------------------- | |
| 388 | my @members; | |
| 389 | if ($perm_ready) { | |
| 390 | @members = $db->db_readwrite_multiple($dbh, qq~ | |
| 391 | SELECT u.id, u.email, u.display_name, u.last_login_at, | |
| 392 | u.permission_groups_id, | |
| 393 | COALESCE(pg.name, 'No role') AS role_name, | |
| 394 | DATE_FORMAT(u.created_at, '%b %e, %Y') AS added_on, | |
| 395 | DATE_FORMAT(u.last_login_at, '%b %e, %Y') AS last_seen | |
| 396 | FROM `${DB}`.users u | |
| 397 | LEFT JOIN `${DB}`.permission_groups pg ON pg.id = u.permission_groups_id | |
| 398 | WHERE u.owner_user_id='$owner_uid' | |
| 399 | ORDER BY u.created_at DESC | |
| 400 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 401 | } | |
| 402 | ||
| 403 | # ---- Load roles available to this owner ----------------------------- | |
| 404 | # Roles = built-in presets (owner_user_id IS NULL) + custom roles | |
| 405 | # created by this owner. Preset rows are shown read-only; custom rows | |
| 406 | # get edit/delete affordances. permission_features_ids comes back too | |
| 407 | # so the template can pre-check the right boxes on the edit form. | |
| 408 | my @roles; | |
| 409 | if ($perm_ready) { | |
| 410 | @roles = $db->db_readwrite_multiple($dbh, qq~ | |
| 411 | SELECT id, name, system_slug, owner_user_id, permission_features_ids | |
| 412 | FROM `${DB}`.permission_groups | |
| 413 | WHERE scope='seller' | |
| 414 | AND group_status=1 | |
| 415 | AND (owner_user_id IS NULL OR owner_user_id='$owner_uid') | |
| 416 | ORDER BY (owner_user_id IS NULL) DESC, name ASC | |
| 417 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 418 | } | |
| 419 | ||
| 420 | # ---- Load the full seller-scope feature catalog --------------------- | |
| 421 | # Used by the create/edit role form as the checkbox grid. Bucketed | |
| 422 | # by a coarse "category" derived from the slug prefix so the form | |
| 423 | # reads as Models / Orders / Messages / etc. rather than a flat list. | |
| 424 | my @feature_catalog; | |
| 425 | if ($perm_ready) { | |
| 426 | @feature_catalog = $db->db_readwrite_multiple($dbh, qq~ | |
| 427 | SELECT id, slug, label, sort_order | |
| 428 | FROM `${DB}`.permission_features | |
| 429 | WHERE scope='seller' | |
| 430 | ORDER BY sort_order ASC, id ASC | |
| 431 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 432 | } | |
| 433 | ||
| 434 | # ---- Build role -> feature-labels map for the "What each role can do" | |
| 435 | # panel. One JOIN against FIND_IN_SET keeps the CSV-as-string design | |
| 436 | # (matches Template.pm's ptag lookup) and lets MySQL do the unpacking. | |
| 437 | my @role_features; | |
| 438 | if ($perm_ready) { | |
| 439 | @role_features = $db->db_readwrite_multiple($dbh, qq~ | |
| 440 | SELECT pg.id AS role_id, pg.name AS role_name, | |
| 441 | pf.label AS feature_label, pf.sort_order | |
| 442 | FROM `${DB}`.permission_groups pg | |
| 443 | JOIN `${DB}`.permission_features pf | |
| 444 | ON FIND_IN_SET(pf.id, pg.permission_features_ids) > 0 | |
| 445 | WHERE pg.scope='seller' | |
| 446 | AND pg.group_status=1 | |
| 447 | AND (pg.owner_user_id IS NULL OR pg.owner_user_id='$owner_uid') | |
| 448 | ORDER BY (pg.owner_user_id IS NULL) DESC, pg.name ASC, pf.sort_order ASC | |
| 449 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 450 | } | |
| 451 | ||
| 452 | $db->db_disconnect($dbh); | |
| 453 | ||
| 454 | # ---- Build template vars -------------------------------------------- | |
| 455 | my @member_rows; | |
| 456 | foreach my $m (@members) { | |
| 457 | my @role_opts; | |
| 458 | foreach my $r (@roles) { | |
| 459 | push @role_opts, { | |
| 460 | value => $r->{id}, | |
| 461 | label => _h($r->{name}), | |
| 462 | selected_attr => ($r->{id} eq ($m->{permission_groups_id} || '') ? ' selected' : ''), | |
| 463 | }; | |
| 464 | } | |
| 465 | push @member_rows, { | |
| 466 | id => $m->{id}, | |
| 467 | email => _h($m->{email}), | |
| 468 | display_name => _h($m->{display_name}), | |
| 469 | role_name => _h($m->{role_name}), | |
| 470 | added_on => _h($m->{added_on} || '-'), | |
| 471 | last_seen => _h($m->{last_seen} || 'Never'), | |
| 472 | role_options => \@role_opts, | |
| 473 | }; | |
| 474 | } | |
| 475 | ||
| 476 | my @add_role_opts; | |
| 477 | foreach my $r (@roles) { | |
| 478 | push @add_role_opts, { | |
| 479 | value => $r->{id}, | |
| 480 | label => _h($r->{name}), | |
| 481 | }; | |
| 482 | } | |
| 483 | ||
| 484 | # Walk @roles directly so even zero-feature custom roles get a card. | |
| 485 | # Enrich each card with feature labels (from the @role_features join) | |
| 486 | # and a "feature_options" list -- a copy of @feature_catalog with a | |
| 487 | # per-row checked flag, so the edit form renders pre-populated. | |
| 488 | my %feature_label_by_role; # role_id -> [label, label, ...] | |
| 489 | foreach my $rf (@role_features) { | |
| 490 | push @{ $feature_label_by_role{ $rf->{role_id} } }, $rf->{feature_label}; | |
| 491 | } | |
| 492 | ||
| 493 | my @role_cards; | |
| 494 | foreach my $r (@roles) { | |
| 495 | my %own_ids = map { $_ => 1 } split(/,/, $r->{permission_features_ids} || ''); | |
| 496 | my @feature_options; | |
| 497 | foreach my $f (@feature_catalog) { | |
| 498 | push @feature_options, { | |
| 499 | id => $f->{id}, | |
| 500 | label => _h($f->{label}), | |
| 501 | checked_attr => $own_ids{ $f->{id} } ? ' checked' : '', | |
| 502 | }; | |
| 503 | } | |
| 504 | my $is_preset = $r->{owner_user_id} ? 0 : 1; | |
| 505 | my @feat_labels = map { { label => _h($_) } } | |
| 506 | @{ $feature_label_by_role{ $r->{id} } || [] }; | |
| 507 | push @role_cards, { | |
| 508 | id => $r->{id}, | |
| 509 | name => _h($r->{name}), | |
| 510 | is_preset => $is_preset, | |
| 511 | is_custom => $is_preset ? 0 : 1, | |
| 512 | feature_count => scalar(@feat_labels), | |
| 513 | features => \@feat_labels, | |
| 514 | has_features => scalar(@feat_labels) ? 1 : 0, | |
| 515 | feature_options => \@feature_options, | |
| 516 | }; | |
| 517 | } | |
| 518 | ||
| 519 | # Catalog also passed flat so the "Create a new role" form (no | |
| 520 | # pre-selected boxes) can render its own checkbox grid. | |
| 521 | my @create_feature_options; | |
| 522 | foreach my $f (@feature_catalog) { | |
| 523 | push @create_feature_options, { | |
| 524 | id => $f->{id}, | |
| 525 | label => _h($f->{label}), | |
| 526 | }; | |
| 527 | } | |
| 528 | ||
| 529 | my $tvars = { | |
| 530 | perm_ready => $perm_ready, | |
| 531 | has_perm_ready => $perm_ready ? 1 : 0, | |
| 532 | flash_msg => _h($flash_msg), | |
| 533 | has_flash => $flash_msg ? 1 : 0, | |
| 534 | flash_is_error => ($flash_kind eq 'error') ? 1 : 0, | |
| 535 | flash_is_success => ($flash_kind eq 'success') ? 1 : 0, | |
| 536 | members => \@member_rows, | |
| 537 | has_members => scalar(@member_rows) ? 1 : 0, | |
| 538 | member_count => scalar(@member_rows), | |
| 539 | add_role_options => \@add_role_opts, | |
| 540 | has_roles => scalar(@add_role_opts) ? 1 : 0, | |
| 541 | role_cards => \@role_cards, | |
| 542 | has_role_cards => scalar(@role_cards) ? 1 : 0, | |
| 543 | create_feature_options => \@create_feature_options, | |
| 544 | has_features_catalog => scalar(@create_feature_options) ? 1 : 0, | |
| 545 | owner_display => _h($me->{display_name} || $me->{email}), | |
| 546 | }; | |
| 547 | ||
| 548 | 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"; | |
| 549 | ||
| 550 | my $body = join('', $tfile->template('repricer_team.html', $tvars, $userinfo)); | |
| 551 | ||
| 552 | $wrap->render({ | |
| 553 | userinfo => $userinfo, | |
| 554 | page_key => 'team', | |
| 555 | title => 'Team Members', | |
| 556 | body => $body, | |
| 557 | }); |