Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/team.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/team.cgi

added on local at 2026-07-01 13:47:45

Added
+557
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 4a014b4925a7
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# WebSTLs - 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#======================================================================
22use strict;
23use warnings;
24
25use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com';
26use CGI;
27use MODS::Template;
28use MODS::DBConnect;
29use MODS::Login;
30use MODS::AffSoft::Config;
31use MODS::AffSoft::Wrapper;
32
33my $q = CGI->new;
34my $form = $q->Vars;
35my $auth = MODS::Login->new;
36my $wrap = MODS::AffSoft::Wrapper->new;
37my $tfile = MODS::Template->new;
38my $db = MODS::DBConnect->new;
39my $cfg = MODS::AffSoft::Config->new;
40my $DB = $cfg->settings('database_name');
41
42$|=1;
43my $userinfo = $auth->login_verify();
44if (!$userinfo) {
45 print "Status: 302 Found\nLocation: /login.cgi\n\n";
46 exit;
47}
48my $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.
60my $dbh = $db->db_connect();
61
62my $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__);
66my $schema_ready = ($have_owner_col && $have_owner_col->{n}) ? 1 : 0;
67
68my $me;
69if ($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
83if ($schema_ready && $me->{owner_user_id}) {
84 # Team members don't manage the team. Send them to their profile.
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.
91my $owner_uid = $uid;
92
93sub _esc {
94 my $s = shift;
95 $s //= '';
96 $s =~ s/\\/\\\\/g;
97 $s =~ s/'/''/g;
98 return $s;
99}
100
101sub _h {
102 my $s = shift;
103 $s //= '';
104 $s =~ s/&/&/g;
105 $s =~ s/</&lt;/g;
106 $s =~ s/>/&gt;/g;
107 $s =~ s/"/&quot;/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.
117my $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__);
121my $perm_ready = ($schema_ready && $have_pg_tbl && $have_pg_tbl->{n}) ? 1 : 0;
122
123# ---- POST handlers --------------------------------------------------
124my $flash_msg = '';
125my $flash_kind = ''; # 'success' or 'error'
126
127my $action = $form->{action} || '';
128
129if ($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}
186elsif ($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}
219elsif ($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}
273elsif ($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}
323elsif ($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}
367elsif ($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 -----------------------------------------------
388my @members;
389if ($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.
408my @roles;
409if ($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.
424my @feature_catalog;
425if ($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.
437my @role_features;
438if ($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 --------------------------------------------
455my @member_rows;
456foreach 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
476my @add_role_opts;
477foreach 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.
488my %feature_label_by_role; # role_id -> [label, label, ...]
489foreach my $rf (@role_features) {
490 push @{ $feature_label_by_role{ $rf->{role_id} } }, $rf->{feature_label};
491}
492
493my @role_cards;
494foreach 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.
521my @create_feature_options;
522foreach my $f (@feature_catalog) {
523 push @create_feature_options, {
524 id => $f->{id},
525 label => _h($f->{label}),
526 };
527}
528
529my $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
548print "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
550my $body = join('', $tfile->template('webstls_team.html', $tvars, $userinfo));
551
552$wrap->render({
553 userinfo => $userinfo,
554 page_key => 'team',
555 title => 'Team Members',
556 body => $body,
557});