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