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

O Operator
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
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 - 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#======================================================================
24use strict;
25use warnings;
26
27use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
28use CGI;
29use MODS::Template;
30use MODS::DBConnect;
31use MODS::Login;
32use MODS::ABForge::Config;
33use MODS::ABForge::Wrapper;
34
35my $q = CGI->new;
36my $form = $q->Vars;
37my $auth = MODS::Login->new;
38my $wrap = MODS::ABForge::Wrapper->new;
39my $tfile = MODS::Template->new;
40my $db = MODS::DBConnect->new;
41my $cfg = MODS::ABForge::Config->new;
42my $DB = $cfg->settings('database_name');
43
44$|=1;
45my $userinfo = $auth->login_verify();
46if (!$userinfo) {
47 print "Status: 302 Found\nLocation: /login.cgi\n\n";
48 exit;
49}
50my $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.
62my $dbh = $db->db_connect();
63
64my $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__);
68my $schema_ready = ($have_owner_col && $have_owner_col->{n}) ? 1 : 0;
69
70my $me;
71if ($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
85if ($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.
93my $owner_uid = $uid;
94
95sub _esc {
96 my $s = shift;
97 $s //= '';
98 $s =~ s/\\/\\\\/g;
99 $s =~ s/'/''/g;
100 return $s;
101}
102
103sub _h {
104 my $s = shift;
105 $s //= '';
106 $s =~ s/&/&/g;
107 $s =~ s/</&lt;/g;
108 $s =~ s/>/&gt;/g;
109 $s =~ s/"/&quot;/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.
119my $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__);
123my $perm_ready = ($schema_ready && $have_pg_tbl && $have_pg_tbl->{n}) ? 1 : 0;
124
125# ---- POST handlers --------------------------------------------------
126my $flash_msg = '';
127my $flash_kind = ''; # 'success' or 'error'
128
129my $action = $form->{action} || '';
130
131if ($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}
188elsif ($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}
221elsif ($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}
275elsif ($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}
325elsif ($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}
369elsif ($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 -----------------------------------------------
390my @members;
391if ($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.
410my @roles;
411if ($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.
426my @feature_catalog;
427if ($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.
439my @role_features;
440if ($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 --------------------------------------------
457my @member_rows;
458foreach 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
478my @add_role_opts;
479foreach 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.
490my %feature_label_by_role; # role_id -> [label, label, ...]
491foreach my $rf (@role_features) {
492 push @{ $feature_label_by_role{ $rf->{role_id} } }, $rf->{feature_label};
493}
494
495my @role_cards;
496foreach 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.
523my @create_feature_options;
524foreach my $f (@feature_catalog) {
525 push @create_feature_options, {
526 id => $f->{id},
527 label => _h($f->{label}),
528 };
529}
530
531my $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
550print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
551
552my $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});