Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/team.cgi
Diff
/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/team.cgi
added on local at 2026-07-01 12:34:46
Added
+839
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 50a05091bc13
to 50a05091bc13
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 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 27 | use MODS::Login; my $login = new MODS::Login; | |
| 28 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 29 | use MODS::Template; my $tfile = new MODS::Template; | |
| 30 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 31 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 32 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 33 | use MODS::PTMatrix::PM; | |
| 34 | use MODS::PTMatrix::Mailer; | |
| 35 | ||
| 36 | ||
| 37 | ||
| 38 | ############################################################# | |
| 39 | ## Get form data | |
| 40 | ############################################################# | |
| 41 | #team_member + team_import need raw values (bio text, CSV body) preserved | |
| 42 | my %rawform = %$form; | |
| 43 | foreach my $line(keys %$form){ | |
| 44 | $form->{$line} =~ s/[^!-~\s]//g; | |
| 45 | $form->{$line} = quotemeta("$form->{$line}"); | |
| 46 | } | |
| 47 | ||
| 48 | ||
| 49 | ||
| 50 | ############################################################ | |
| 51 | # Program Controls | |
| 52 | ############################################################ | |
| 53 | $|=1; | |
| 54 | my $userinfo = $login->login_verify(); | |
| 55 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'team'; | |
| 56 | ||
| 57 | if(!$userinfo){ | |
| 58 | if($entry eq 'team_action' || $entry eq 'team_import'){ | |
| 59 | print qq~Status: 302 Found\nLocation: $url->{login}\n\n~; | |
| 60 | exit; | |
| 61 | } | |
| 62 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n<script>window.location="$url->{login}";</script>~; | |
| 63 | exit; | |
| 64 | } | |
| 65 | ||
| 66 | if($entry eq 'team_member'){ | |
| 67 | #team_member has its own auth inside (self OR same-company admin OR super admin) | |
| 68 | my $target_id = MODS::PTMatrix::PM::safe_int($form->{user_id}); | |
| 69 | if(!$target_id){print qq~Status: 302 Found\nLocation: /team.cgi\n\n~; exit;} | |
| 70 | &team_member($target_id); | |
| 71 | }else{ | |
| 72 | #team, team_action, team_import all require company_admin | |
| 73 | if(!MODS::PTMatrix::PM::is_company_admin($userinfo)){ | |
| 74 | if($entry eq 'team_action'){print qq~Status: 302 Found\nLocation: $url->{dashboard}\n\n~; exit;} | |
| 75 | if($entry eq 'team_import'){print qq~Status: 302 Found\nLocation: /dashboard.cgi\n\n~; exit;} | |
| 76 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n<script>window.location="$url->{dashboard}";</script>~; | |
| 77 | exit; | |
| 78 | } | |
| 79 | if($entry eq 'team_action'){ | |
| 80 | my $act = $form->{act} || ''; | |
| 81 | if ($act eq 'invite') {&invite_user;} | |
| 82 | elsif($act eq 'remove') {&remove_user;} | |
| 83 | elsif($act eq 'change_role') {&change_role;} | |
| 84 | elsif($act eq 'change_rate') {&change_rate;} | |
| 85 | else {&bounce($url->{team});} | |
| 86 | }elsif($entry eq 'team_import'){ | |
| 87 | my $act = $form->{act} || ''; | |
| 88 | if(($ENV{REQUEST_METHOD} || '') eq 'POST' && $act eq 'preview'){&do_preview;} | |
| 89 | elsif(($ENV{REQUEST_METHOD} || '') eq 'POST' && $act eq 'commit'){&do_commit;} | |
| 90 | else{&render_form;} | |
| 91 | }else{ | |
| 92 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~; | |
| 93 | &team_list; | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | ||
| 98 | ############################################################ | |
| 99 | # Subroutines | |
| 100 | ############################################################ | |
| 101 | sub team_list{ | |
| 102 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 103 | ||
| 104 | my $dbh = $db->db_connect(); | |
| 105 | ||
| 106 | #------------------------------------------------------------------------------------------------------ | |
| 107 | #All users in this company, ordered active-first then by name | |
| 108 | my $sql_u = qq~SELECT u.id, u.email, u.display_name, u.job_title, u.role, u.avatar_color, u.account_status, u.last_login_at, u.created_at, u.email_verified_at, u.invite_token, u.auth_provider, u.sso_last_sync_at, u.permission_groups_id, u.hourly_rate_cents, pg.name AS perm_group_name FROM `${database_name}`.users u LEFT JOIN `${database_name}`.permission_groups pg ON pg.id = u.permission_groups_id WHERE u.company_id='$cid' ORDER BY FIELD(u.account_status,'active','pending_verification','suspended','closed'), u.display_name~; | |
| 109 | my @users = $db->db_readwrite_multiple($dbh, $sql_u, $ENV{SCRIPT_NAME}, __LINE__); | |
| 110 | #------------------------------------------------------------------------------------------------------ | |
| 111 | ||
| 112 | #------------------------------------------------------------------------------------------------------ | |
| 113 | #Active permission groups for the per-user dropdown | |
| 114 | my $sql_pg = qq~SELECT id, name FROM `${database_name}`.permission_groups WHERE company_id='$cid' AND group_status='1' ORDER BY name~; | |
| 115 | my @perm_groups = $db->db_readwrite_multiple($dbh, $sql_pg, $ENV{SCRIPT_NAME}, __LINE__); | |
| 116 | #------------------------------------------------------------------------------------------------------ | |
| 117 | ||
| 118 | $db->db_disconnect($dbh); | |
| 119 | ||
| 120 | my $has_perm_groups = scalar(@perm_groups) ? 1 : 0; | |
| 121 | my $self_id = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 122 | ||
| 123 | foreach my $usr(@users){ | |
| 124 | $usr->{initials} = MODS::PTMatrix::PM::initials($usr->{display_name}); | |
| 125 | $usr->{display_name} = MODS::PTMatrix::PM::h($usr->{display_name}); | |
| 126 | $usr->{is_pending} = ($usr->{account_status} eq 'pending_verification') ? 1 : 0; | |
| 127 | $usr->{is_self} = ($usr->{id} == $self_id) ? 1 : 0; | |
| 128 | $usr->{role_label} = ucfirst($usr->{role}); $usr->{role_label} =~ s/_/ /g; | |
| 129 | $usr->{is_sso} = ($usr->{auth_provider} eq 'sso') ? 1 : 0; | |
| 130 | $usr->{has_perm_group} = $usr->{permission_groups_id} ? 1 : 0; | |
| 131 | $usr->{rate_display} = defined($usr->{hourly_rate_cents}) ? sprintf("%.2f", $usr->{hourly_rate_cents} / 100) : ''; | |
| 132 | $usr->{perm_group_name} = MODS::PTMatrix::PM::h($usr->{perm_group_name} || ''); | |
| 133 | ||
| 134 | #Pre-decorate per-user dropdown options with selected flag (template can't | |
| 135 | #cross-reference $loop1 from inside a nested $loop2) | |
| 136 | $usr->{perm_groups_for_user} = [ | |
| 137 | map { +{ | |
| 138 | id => $_->{id}, | |
| 139 | name => MODS::PTMatrix::PM::h($_->{name}), | |
| 140 | is_selected => ($usr->{permission_groups_id} && $usr->{permission_groups_id} == $_->{id}) ? 1 : 0, | |
| 141 | } } @perm_groups | |
| 142 | ]; | |
| 143 | ||
| 144 | $usr->{invite_url} = $usr->{is_pending} && $usr->{invite_token} | |
| 145 | ? 'https://ptmatrix.3dshawn.com/accept_invite.cgi?token=' . $usr->{invite_token} | |
| 146 | : ''; | |
| 147 | } | |
| 148 | ||
| 149 | my ($seats_used, $seats_max) = MODS::PTMatrix::PM::company_seat_info($cid); | |
| 150 | my $is_overcap = ($form->{err}||'') eq 'overcap' ? 1 : 0; | |
| 151 | ||
| 152 | #Create all template variables | |
| 153 | my $tvars; | |
| 154 | $tvars->{users} = \@users; | |
| 155 | $tvars->{has_users} = scalar(@users) ? 1 : 0; | |
| 156 | $tvars->{show_invited} = ($form->{invited} ? 1 : 0); | |
| 157 | $tvars->{invited_link} = $form->{invite_link} || ''; | |
| 158 | $tvars->{has_perm_groups} = $has_perm_groups; | |
| 159 | $tvars->{is_overcap} = $is_overcap; | |
| 160 | $tvars->{seats_used} = $seats_used; | |
| 161 | $tvars->{seats_max} = $seats_max; | |
| 162 | $tvars->{has_seat_cap} = defined($seats_max) ? 1 : 0; | |
| 163 | ||
| 164 | my $body = join('', $tfile->template('tf_team.html', $tvars, $userinfo)); | |
| 165 | ||
| 166 | $load->render({ | |
| 167 | userinfo => $userinfo, | |
| 168 | page_key => 'team', | |
| 169 | title => 'Team Members', | |
| 170 | body => $body, | |
| 171 | }); | |
| 172 | } | |
| 173 | ||
| 174 | sub bounce{ | |
| 175 | my($target) = @_; | |
| 176 | print qq~Status: 302 Found\nLocation: $target\n\n~; | |
| 177 | exit; | |
| 178 | } | |
| 179 | ||
| 180 | sub invite_user{ | |
| 181 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 182 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 183 | my $email = $form->{email} || ''; | |
| 184 | my $name = $form->{display_name} || ''; | |
| 185 | my $role = ($form->{role} =~ /^(member|observer|manager|company_admin)$/) ? $form->{role} : 'member'; | |
| 186 | ||
| 187 | if(!MODS::PTMatrix::PM::can_add_user($cid)){ | |
| 188 | &bounce("$url->{team}&err=overcap"); | |
| 189 | } | |
| 190 | ||
| 191 | my ($invited_id, $token) = $login->invite_user($cid, $email, $name, $role, $uid); | |
| 192 | if(!$invited_id || !$token){ | |
| 193 | &bounce("$url->{team}&err=dup"); | |
| 194 | } | |
| 195 | ||
| 196 | MODS::PTMatrix::PM::audit($cid, $uid, 'user.invite', 'user', $invited_id); | |
| 197 | ||
| 198 | #Send the invite email best-effort; the in-app link is still surfaced in the UI | |
| 199 | my $link = 'https://ptmatrix.3dshawn.com/accept_invite.cgi?token=' . $token; | |
| 200 | my $mailer = MODS::PTMatrix::Mailer->new; | |
| 201 | my $body = qq~<p>Hi $name,</p><p>$userinfo->{display_name} ($userinfo->{email}) invited you to join <strong>$userinfo->{company_name}</strong> on PTMatrix.</p><p><a href="$link" style="display:inline-block;background:#5aa9ff;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Accept invitation</a></p><p>Or copy this link:<br><code>$link</code></p><p style="color:#888;font-size:12px">This link is good for 14 days.</p>~; | |
| 202 | $mailer->send( | |
| 203 | to => $email, | |
| 204 | subject => "$userinfo->{display_name} invited you to $userinfo->{company_name} on PTMatrix", | |
| 205 | body_html => $body, | |
| 206 | ); | |
| 207 | ||
| 208 | my $q_link = CGI::escape($link); | |
| 209 | &bounce("$url->{team}&invited=1&invite_link=$q_link"); | |
| 210 | } | |
| 211 | ||
| 212 | sub remove_user{ | |
| 213 | my $self_uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 214 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 215 | my $tid = MODS::PTMatrix::PM::safe_int($form->{user_id}); | |
| 216 | &bounce($url->{team}) if $tid == $self_uid; | |
| 217 | ||
| 218 | #Suspend not delete - preserves task history. Also kill any live sessions. | |
| 219 | my $dbh = $db->db_connect(); | |
| 220 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET account_status='suspended' WHERE id='$tid' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 221 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.user_sessions SET revoked_at=NOW() WHERE user_id='$tid' AND revoked_at IS NULL~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 222 | $db->db_disconnect($dbh); | |
| 223 | ||
| 224 | MODS::PTMatrix::PM::audit($cid, $self_uid, 'user.remove', 'user', $tid); | |
| 225 | &bounce("$url->{team}&removed=1"); | |
| 226 | } | |
| 227 | ||
| 228 | sub change_role{ | |
| 229 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 230 | my $tid = MODS::PTMatrix::PM::safe_int($form->{user_id}); | |
| 231 | my $role = ($form->{role} =~ /^(member|observer|manager|company_admin)$/) ? $form->{role} : 'member'; | |
| 232 | ||
| 233 | my $dbh = $db->db_connect(); | |
| 234 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET role='$role' WHERE id='$tid' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 235 | $db->db_disconnect($dbh); | |
| 236 | ||
| 237 | &bounce("$url->{team}&roled=1"); | |
| 238 | } | |
| 239 | ||
| 240 | sub change_rate{ | |
| 241 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 242 | my $tid = MODS::PTMatrix::PM::safe_int($form->{user_id}); | |
| 243 | my $raw = $form->{hourly_rate} // ''; | |
| 244 | $raw =~ s/^\s+|\s+$//g; | |
| 245 | ||
| 246 | my $dbh = $db->db_connect(); | |
| 247 | if($raw eq ''){ | |
| 248 | #Blank = clear override, inherit company default | |
| 249 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET hourly_rate_cents=NULL WHERE id='$tid' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 250 | }elsif($raw =~ /^\d+(\.\d+)?$/){ | |
| 251 | my $cents = int(($raw + 0) * 100); | |
| 252 | $cents = 0 if $cents < 0; | |
| 253 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET hourly_rate_cents='$cents' WHERE id='$tid' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 254 | } | |
| 255 | $db->db_disconnect($dbh); | |
| 256 | ||
| 257 | &bounce("$url->{team}&rate=1"); | |
| 258 | } | |
| 259 | ||
| 260 | #============================================================ | |
| 261 | # team_member entry: view + edit a single team member | |
| 262 | #============================================================ | |
| 263 | sub tm_stat_html{ | |
| 264 | my($label, $value) = @_; | |
| 265 | return qq~<div style="background:rgba(20,20,30,.4);border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:14px"><div style="font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-bottom:4px">$label</div><div style="font-size:20px;color:#fff;font-weight:700">$value</div></div>~; | |
| 266 | } | |
| 267 | ||
| 268 | sub tm_opts_html{ | |
| 269 | my($items, $key, $label_key, $current) = @_; | |
| 270 | my @h; | |
| 271 | for my $i(@$items){ | |
| 272 | my $sel = (defined $current && $i->{$key} eq $current) ? ' selected' : ''; | |
| 273 | my $lbl = MODS::PTMatrix::PM::h($i->{$label_key}); | |
| 274 | push @h, qq~<option value="$i->{$key}"$sel>$lbl</option>~; | |
| 275 | } | |
| 276 | return join("\n", @h); | |
| 277 | } | |
| 278 | ||
| 279 | sub tm_role_opts_html{ | |
| 280 | my $cur = shift; | |
| 281 | my @opts; | |
| 282 | for my $r(qw(company_admin manager member observer)){ | |
| 283 | my $sel = $cur eq $r ? ' selected' : ''; | |
| 284 | my $lbl = ucfirst($r); | |
| 285 | $lbl =~ s/_/ /g; | |
| 286 | push @opts, qq~<option value="$r"$sel>$lbl</option>~; | |
| 287 | } | |
| 288 | return join("\n", @opts); | |
| 289 | } | |
| 290 | ||
| 291 | sub tm_apply_update{ | |
| 292 | my($dbh, $target_id, $can_edit_admin_fields, $is_self) = @_; | |
| 293 | my @sets; | |
| 294 | for my $f(qw(display_name job_title phone bio_short timezone)){ | |
| 295 | next unless defined $rawform{$f}; | |
| 296 | my $v = MODS::PTMatrix::PM::sql_quote(substr($rawform{$f}, 0, 255)); | |
| 297 | push @sets, "$f='$v'"; | |
| 298 | } | |
| 299 | if($can_edit_admin_fields && !$is_self){ | |
| 300 | for my $f(qw(role)){ | |
| 301 | if(defined $rawform{$f}){ | |
| 302 | my $v = $rawform{$f}; | |
| 303 | $v =~ s/[^a-z_]//g; | |
| 304 | push @sets, "role='" . MODS::PTMatrix::PM::sql_quote($v) . "'" if length $v; | |
| 305 | } | |
| 306 | } | |
| 307 | if(defined $rawform{permission_groups_id}){ | |
| 308 | my $pgid = MODS::PTMatrix::PM::safe_int($rawform{permission_groups_id}); | |
| 309 | push @sets, "permission_groups_id=" . ($pgid ? "'$pgid'" : 'NULL'); | |
| 310 | } | |
| 311 | if(defined $rawform{manager_user_id}){ | |
| 312 | my $mid = MODS::PTMatrix::PM::safe_int($rawform{manager_user_id}); | |
| 313 | push @sets, "manager_user_id=" . ($mid ? "'$mid'" : 'NULL'); | |
| 314 | } | |
| 315 | if(defined $rawform{hourly_rate}){ | |
| 316 | my $r = $rawform{hourly_rate}; | |
| 317 | $r =~ s/[^\d\.]//g; | |
| 318 | if(length $r){ | |
| 319 | my $cents = int($r * 100); | |
| 320 | push @sets, "hourly_rate_cents='$cents'"; | |
| 321 | }else{ | |
| 322 | push @sets, "hourly_rate_cents=NULL"; | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | if(@sets){ | |
| 327 | my $set = join(', ', @sets); | |
| 328 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET $set WHERE id='$target_id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 329 | return ('ok', 'Saved.'); | |
| 330 | } | |
| 331 | return ('', ''); | |
| 332 | } | |
| 333 | ||
| 334 | sub team_member{ | |
| 335 | my($target_id) = @_; | |
| 336 | my $viewer_id = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 337 | my $viewer_cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 338 | my $is_admin = MODS::PTMatrix::PM::is_company_admin($userinfo); | |
| 339 | my $is_super = $userinfo->{is_super_admin} ? 1 : 0; | |
| 340 | ||
| 341 | my $dbh = $db->db_connect(); | |
| 342 | ||
| 343 | my $tgt_co = $db->db_readwrite($dbh, qq~SELECT company_id FROM `${database_name}`.users WHERE id='$target_id' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 344 | if(!$tgt_co || !$tgt_co->{company_id}){ | |
| 345 | $db->db_disconnect($dbh); | |
| 346 | print qq~Status: 404 Not Found\nContent-Type: text/plain\n\nMember not found.\n~; | |
| 347 | exit; | |
| 348 | } | |
| 349 | ||
| 350 | my $is_self = ($target_id == $viewer_id) ? 1 : 0; | |
| 351 | my $same_company = ($tgt_co->{company_id} == $viewer_cid) ? 1 : 0; | |
| 352 | my $can_edit_admin_fields = ($is_super || ($is_admin && $same_company)) ? 1 : 0; | |
| 353 | my $can_edit_self_fields = $can_edit_admin_fields || $is_self ? 1 : 0; | |
| 354 | ||
| 355 | if(!$can_edit_self_fields){ | |
| 356 | $db->db_disconnect($dbh); | |
| 357 | print qq~Status: 403 Forbidden\nContent-Type: text/plain\n\nNot allowed.\n~; | |
| 358 | exit; | |
| 359 | } | |
| 360 | ||
| 361 | my($flash_kind, $flash_msg) = ('', ''); | |
| 362 | if(($ENV{REQUEST_METHOD} || '') eq 'POST'){ | |
| 363 | my $act = $rawform{act} || ''; | |
| 364 | if($act eq 'update'){ | |
| 365 | ($flash_kind, $flash_msg) = &tm_apply_update($dbh, $target_id, $can_edit_admin_fields, $is_self); | |
| 366 | }elsif($can_edit_admin_fields && $act eq 'suspend' && !$is_self){ | |
| 367 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET account_status='suspended' WHERE id='$target_id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 368 | ($flash_kind, $flash_msg) = ('ok', 'Account suspended. The user can no longer sign in.'); | |
| 369 | }elsif($can_edit_admin_fields && $act eq 'activate' && !$is_self){ | |
| 370 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET account_status='active' WHERE id='$target_id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 371 | ($flash_kind, $flash_msg) = ('ok', 'Account reactivated.'); | |
| 372 | } | |
| 373 | } | |
| 374 | ||
| 375 | my $member = $db->db_readwrite($dbh, qq~SELECT u.id, u.company_id, u.email, u.display_name, u.job_title, u.role, u.avatar_color, u.account_status, u.last_login_at, u.created_at, u.email_verified_at, u.auth_provider, u.permission_groups_id, u.hourly_rate_cents, u.two_factor_enabled, u.phone, u.bio_short, u.timezone, u.manager_user_id, u.is_super_admin, u.is_admin, u.is_hr_admin, pg.name AS perm_group_name, c.name AS company_name, mgr.display_name AS manager_name FROM `${database_name}`.users u LEFT JOIN `${database_name}`.permission_groups pg ON pg.id = u.permission_groups_id LEFT JOIN `${database_name}`.companies c ON c.id = u.company_id LEFT JOIN `${database_name}`.users mgr ON mgr.id = u.manager_user_id WHERE u.id='$target_id' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 376 | ||
| 377 | my @perm_groups = $db->db_readwrite_multiple($dbh, qq~SELECT id, name FROM `${database_name}`.permission_groups WHERE company_id='$tgt_co->{company_id}' AND group_status='1' ORDER BY name~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 378 | my @co_users = $db->db_readwrite_multiple($dbh, qq~SELECT id, display_name FROM `${database_name}`.users WHERE company_id='$tgt_co->{company_id}' AND id != '$target_id' AND account_status IN ('active','pending_verification') ORDER BY display_name~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 379 | ||
| 380 | my $tasks_open = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM `${database_name}`.tasks WHERE assigned_user_id='$target_id' AND status NOT IN ('done','cancelled','archived','pending_archive')~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 381 | my $tasks_done = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM `${database_name}`.tasks WHERE assigned_user_id='$target_id' AND status='done'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 382 | my $projects_n = $db->db_readwrite($dbh, qq~SELECT COUNT(DISTINCT project_id) AS n FROM `${database_name}`.project_members WHERE user_id='$target_id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 383 | my $hours_30d = $db->db_readwrite($dbh, qq~SELECT COALESCE(SUM(minutes),0) AS m FROM `${database_name}`.time_entries WHERE user_id='$target_id' AND started_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 384 | ||
| 385 | $db->db_disconnect($dbh); | |
| 386 | ||
| 387 | my $name_h = MODS::PTMatrix::PM::h($member->{display_name} || $member->{email}); | |
| 388 | my $email_h = MODS::PTMatrix::PM::h($member->{email}); | |
| 389 | my $initials = MODS::PTMatrix::PM::initials($member->{display_name} || $member->{email}); | |
| 390 | my $color = MODS::PTMatrix::PM::h($member->{avatar_color} || '#5aa9ff'); | |
| 391 | my $role = $member->{role} || 'member'; | |
| 392 | my $role_lbl = ucfirst($role); $role_lbl =~ s/_/ /g; | |
| 393 | my $status = $member->{account_status} || 'active'; | |
| 394 | my $auth = ($member->{auth_provider} || 'manual') eq 'sso' ? 'SSO' : 'Password'; | |
| 395 | my $perm_grp = MODS::PTMatrix::PM::h($member->{perm_group_name} || 'role defaults'); | |
| 396 | my $company_h = MODS::PTMatrix::PM::h($member->{company_name} || ''); | |
| 397 | my $rate_input = defined($member->{hourly_rate_cents}) && length($member->{hourly_rate_cents}) | |
| 398 | ? sprintf('%.2f', $member->{hourly_rate_cents} / 100) : ''; | |
| 399 | my $tasks_open_n = ($tasks_open && $tasks_open->{n}) ? $tasks_open->{n} + 0 : 0; | |
| 400 | my $tasks_done_n = ($tasks_done && $tasks_done->{n}) ? $tasks_done->{n} + 0 : 0; | |
| 401 | my $projects_count = ($projects_n && $projects_n->{n}) ? $projects_n->{n} + 0 : 0; | |
| 402 | my $hours_total = ($hours_30d && $hours_30d->{m}) ? sprintf('%.1f', $hours_30d->{m} / 60) : '0.0'; | |
| 403 | my $status_color = { active => '#22c55e', pending_verification => '#f59e0b', suspended => '#ef4444', closed => '#9ca3af' }->{$status} || '#9ca3af'; | |
| 404 | ||
| 405 | my $title_h_attr = MODS::PTMatrix::PM::h($member->{job_title} || ''); | |
| 406 | my $phone_h_attr = MODS::PTMatrix::PM::h($member->{phone} || ''); | |
| 407 | my $tz_h_attr = MODS::PTMatrix::PM::h($member->{timezone} || ''); | |
| 408 | my $bio_h_attr = MODS::PTMatrix::PM::h($member->{bio_short} || ''); | |
| 409 | my $name_attr = MODS::PTMatrix::PM::h($member->{display_name} || ''); | |
| 410 | ||
| 411 | print qq~Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n~; | |
| 412 | ||
| 413 | my $body = qq~<div class="page-pad" style="padding:0"> | |
| 414 | <div style="margin-bottom:12px"><a href="/team.cgi" style="color:#9ca3af;font-size:13px;text-decoration:none">← Back to Team Members</a></div> | |
| 415 | ~; | |
| 416 | ||
| 417 | if($flash_msg){ | |
| 418 | my $col = $flash_kind eq 'ok' ? '#22c55e' : '#ef4444'; | |
| 419 | my $bg = $flash_kind eq 'ok' ? 'rgba(34,197,94,.08)' : 'rgba(239,68,68,.08)'; | |
| 420 | my $brd = $flash_kind eq 'ok' ? 'rgba(34,197,94,.35)' : 'rgba(239,68,68,.35)'; | |
| 421 | $body .= qq~<div style="padding:12px 16px;border:1px solid $brd;background:$bg;color:$col;border-radius:10px;margin-bottom:20px;font-size:14px">$flash_msg</div>~; | |
| 422 | } | |
| 423 | ||
| 424 | $body .= qq~ | |
| 425 | <div style="display:flex;align-items:center;gap:18px;margin-bottom:8px;flex-wrap:wrap"> | |
| 426 | <div class="avatar avatar-lg" style="background:$color;width:56px;height:56px;font-size:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;flex-shrink:0">$initials</div> | |
| 427 | <div style="flex:1;min-width:240px"> | |
| 428 | <h1 style="margin:0;font-size:24px;color:#fff;display:flex;align-items:center;gap:10px;flex-wrap:wrap"> | |
| 429 | $name_h | |
| 430 | <span style="background:rgba(90,169,255,.18);color:#5aa9ff;font-size:11px;padding:3px 10px;border-radius:99px;font-weight:600">$role_lbl</span> | |
| 431 | <span style="background:${status_color}22;color:$status_color;font-size:11px;padding:3px 10px;border-radius:99px;font-weight:600;text-transform:uppercase">$status</span> | |
| 432 | ~; | |
| 433 | $body .= '<span style="background:rgba(167,139,250,.15);color:#a78bfa;font-size:10px;padding:2px 8px;border-radius:99px;font-weight:700">SUPER ADMIN</span>' if $member->{is_super_admin}; | |
| 434 | $body .= '<span style="background:rgba(34,197,94,.15);color:#22c55e;font-size:10px;padding:2px 8px;border-radius:99px;font-weight:700">HR</span>' if $member->{is_hr_admin}; | |
| 435 | $body .= qq~ | |
| 436 | </h1> | |
| 437 | <div style="color:#9ca3af;font-size:13px;margin-top:4px"><a href="mailto:$email_h" style="color:#5aa9ff;text-decoration:none">$email_h</a> · $company_h</div> | |
| 438 | </div> | |
| 439 | </div> | |
| 440 | ||
| 441 | <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin:24px 0"> | |
| 442 | ~ . &tm_stat_html('Open tasks', $tasks_open_n) . qq~ | |
| 443 | ~ . &tm_stat_html('Done tasks', $tasks_done_n) . qq~ | |
| 444 | ~ . &tm_stat_html('Projects', $projects_count) . qq~ | |
| 445 | ~ . &tm_stat_html('Hours / 30d', $hours_total) . qq~ | |
| 446 | </div> | |
| 447 | ~; | |
| 448 | ||
| 449 | my $perm_opts = '<option value="">(role defaults)</option>' . &tm_opts_html(\@perm_groups, 'id', 'name', $member->{permission_groups_id}); | |
| 450 | my $mgr_opts = '<option value="">(none)</option>' . &tm_opts_html(\@co_users, 'id', 'display_name', $member->{manager_user_id}); | |
| 451 | my $role_opts = &tm_role_opts_html($role); | |
| 452 | ||
| 453 | $body .= qq~ | |
| 454 | <form method="POST" style="background:rgba(20,20,30,.4);border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:24px;margin-bottom:18px"> | |
| 455 | <input type="hidden" name="act" value="update"> | |
| 456 | <input type="hidden" name="user_id" value="$target_id"> | |
| 457 | <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#5aa9ff;font-weight:700;margin-bottom:14px">Profile</div> | |
| 458 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px"> | |
| 459 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Display name</div><input class="input" name="display_name" value="$name_attr" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box"></label> | |
| 460 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Job title</div><input class="input" name="job_title" value="$title_h_attr" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box"></label> | |
| 461 | </div> | |
| 462 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px"> | |
| 463 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Phone</div><input class="input" name="phone" value="$phone_h_attr" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box"></label> | |
| 464 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Timezone</div><input class="input" name="timezone" value="$tz_h_attr" placeholder="America/Denver" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box"></label> | |
| 465 | </div> | |
| 466 | <label style="display:block;margin-bottom:18px"><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Short bio</div><textarea name="bio_short" rows="2" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;background:#0f1014;border:1px solid rgba(255,255,255,.1);border-radius:6px;color:#fff;resize:vertical">$bio_h_attr</textarea></label> | |
| 467 | ~; | |
| 468 | ||
| 469 | if($can_edit_admin_fields && !$is_self){ | |
| 470 | $body .= qq~ | |
| 471 | <div style="border-top:1px solid rgba(255,255,255,.08);padding-top:18px"> | |
| 472 | <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#a78bfa;font-weight:700;margin-bottom:14px">Admin: access & billing</div> | |
| 473 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px"> | |
| 474 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Role</div><select class="select" name="role" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box">$role_opts</select></label> | |
| 475 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Permission group</div><select class="select" name="permission_groups_id" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box">$perm_opts</select></label> | |
| 476 | </div> | |
| 477 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px"> | |
| 478 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Manager</div><select class="select" name="manager_user_id" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box">$mgr_opts</select></label> | |
| 479 | <label><div style="font-size:12px;color:#9ca3af;margin-bottom:6px">Hourly rate (\$ / hr)</div><input class="input" name="hourly_rate" value="$rate_input" placeholder="(default)" inputmode="decimal" style="padding:8px 10px;font-size:13px;width:100%;box-sizing:border-box"></label> | |
| 480 | </div> | |
| 481 | </div> | |
| 482 | ~; | |
| 483 | }else{ | |
| 484 | $body .= qq~ | |
| 485 | <div style="border-top:1px solid rgba(255,255,255,.08);padding-top:18px;margin-top:6px"> | |
| 486 | <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#9ca3af;font-weight:700;margin-bottom:10px">Access & billing (read-only)</div> | |
| 487 | <div style="font-size:13px;color:#9ca3af;line-height:1.7"> | |
| 488 | Role: <span style="color:#e5e7eb">$role_lbl</span> · | |
| 489 | Permission group: <span style="color:#e5e7eb">$perm_grp</span> · | |
| 490 | Auth: <span style="color:#e5e7eb">$auth</span>~ . ($member->{two_factor_enabled} ? ' <span style="color:#22c55e">+ 2FA</span>' : '') . qq~ · | |
| 491 | Last login: <span style="color:#e5e7eb">~ . ($member->{last_login_at} || 'never') . qq~</span> | |
| 492 | </div> | |
| 493 | </div> | |
| 494 | ~; | |
| 495 | } | |
| 496 | ||
| 497 | $body .= qq~ | |
| 498 | <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:18px"> | |
| 499 | <button type="submit" class="btn btn-primary">Save changes</button> | |
| 500 | </div> | |
| 501 | </form> | |
| 502 | ~; | |
| 503 | ||
| 504 | if($can_edit_admin_fields && !$is_self){ | |
| 505 | my $is_suspended = $status eq 'suspended' ? 1 : 0; | |
| 506 | $body .= qq~ | |
| 507 | <div style="background:linear-gradient(135deg, rgba(239,68,68,.04), rgba(239,68,68,.02));border:1px solid rgba(239,68,68,.25);border-radius:12px;padding:18px"> | |
| 508 | <div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#ef4444;font-weight:700;margin-bottom:10px">Account controls</div> | |
| 509 | <div style="display:flex;gap:8px;flex-wrap:wrap"> | |
| 510 | ~; | |
| 511 | if($is_suspended){ | |
| 512 | $body .= qq~<form method="POST" style="display:inline"><input type="hidden" name="act" value="activate"><input type="hidden" name="user_id" value="$target_id"><button type="submit" class="btn btn-sm" style="background:#22c55e;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer">Reactivate</button></form>~; | |
| 513 | }else{ | |
| 514 | $body .= qq~<form method="POST" style="display:inline" onsubmit="return confirm('Suspend this account? The user will be unable to sign in until reactivated.')"><input type="hidden" name="act" value="suspend"><input type="hidden" name="user_id" value="$target_id"><button type="submit" class="btn btn-sm" style="background:transparent;border:1px solid rgba(239,68,68,.5);color:#ef4444;padding:6px 14px;border-radius:6px;cursor:pointer">Suspend account</button></form>~; | |
| 515 | } | |
| 516 | $body .= qq~<a href="mailto:$email_h" class="btn btn-ghost btn-sm" style="padding:6px 14px;font-size:13px">Email directly</a>~; | |
| 517 | $body .= '</div></div>'; | |
| 518 | } | |
| 519 | ||
| 520 | $body .= '</div>'; | |
| 521 | ||
| 522 | $load->render({ | |
| 523 | userinfo => $userinfo, | |
| 524 | page_key => 'team_members', | |
| 525 | title => "Member \xe2\x80\x94 $name_h", | |
| 526 | body => $body, | |
| 527 | }); | |
| 528 | } | |
| 529 | ||
| 530 | #============================================================ | |
| 531 | # team_import entry: CSV bulk invite | |
| 532 | #============================================================ | |
| 533 | sub imp_h{ | |
| 534 | my $s = shift // ''; | |
| 535 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 536 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 537 | return $s; | |
| 538 | } | |
| 539 | ||
| 540 | sub imp_csv_cell{ | |
| 541 | my $s = shift // ''; | |
| 542 | if($s =~ /[",\n]/){$s =~ s/"/""/g; return qq~"$s"~;} | |
| 543 | return $s; | |
| 544 | } | |
| 545 | ||
| 546 | sub imp_split_csv_line{ | |
| 547 | my $line = shift; | |
| 548 | my @cells; | |
| 549 | my $cur = ''; | |
| 550 | my $in_q = 0; | |
| 551 | for(my $i = 0; $i < length($line); $i++){ | |
| 552 | my $ch = substr($line, $i, 1); | |
| 553 | if($in_q){ | |
| 554 | if($ch eq '"' && substr($line, $i+1, 1) eq '"'){$cur .= '"'; $i++;} | |
| 555 | elsif($ch eq '"'){$in_q = 0;} | |
| 556 | else{$cur .= $ch;} | |
| 557 | }else{ | |
| 558 | if($ch eq '"'){$in_q = 1;} | |
| 559 | elsif($ch eq ','){push @cells, $cur; $cur = '';} | |
| 560 | else{$cur .= $ch;} | |
| 561 | } | |
| 562 | } | |
| 563 | push @cells, $cur; | |
| 564 | return @cells; | |
| 565 | } | |
| 566 | ||
| 567 | sub imp_parse_csv{ | |
| 568 | my $text = shift; | |
| 569 | $text =~ s/\r\n/\n/g; | |
| 570 | $text =~ s/\r/\n/g; | |
| 571 | my @lines = split /\n/, $text; | |
| 572 | return () unless @lines; | |
| 573 | my $hdr = shift @lines; | |
| 574 | my @cols = map { my $c = lc($_); $c =~ s/^\s+|\s+$//g; $c =~ s/^"|"$//g; $c } split(/,/, $hdr); | |
| 575 | my @out; | |
| 576 | foreach my $line(@lines){ | |
| 577 | next unless length($line); | |
| 578 | my @cells = &imp_split_csv_line($line); | |
| 579 | my %row; | |
| 580 | for(my $i = 0; $i < @cols; $i++){ | |
| 581 | my $v = $cells[$i] // ''; | |
| 582 | $v =~ s/^\s+|\s+$//g; | |
| 583 | $row{ $cols[$i] } = $v; | |
| 584 | } | |
| 585 | push @out, \%row; | |
| 586 | } | |
| 587 | return @out; | |
| 588 | } | |
| 589 | ||
| 590 | sub imp_build_plan{ | |
| 591 | my($dbh, $csv_text) = @_; | |
| 592 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 593 | my %VALID_ROLES = map { $_ => 1 } qw(member observer manager company_admin); | |
| 594 | ||
| 595 | my @rows = &imp_parse_csv($csv_text); | |
| 596 | my @plan; | |
| 597 | my %seen_email; | |
| 598 | ||
| 599 | foreach my $r(@rows){ | |
| 600 | my $email = lc($r->{email} || ''); | |
| 601 | $email =~ s/^\s+|\s+$//g; | |
| 602 | my $name = $r->{display_name} || $r->{name} || ''; | |
| 603 | my $role = lc($r->{role} || 'member'); | |
| 604 | $role = 'member' unless $VALID_ROLES{$role}; | |
| 605 | ||
| 606 | if($email !~ /^[^@\s]+\@[^@\s]+\.[^@\s]+$/){ | |
| 607 | push @plan, { email => $email || '(blank)', name => $name, role => $role, outcome => 'error', detail => 'invalid email' }; | |
| 608 | next; | |
| 609 | } | |
| 610 | if($seen_email{$email}++){ | |
| 611 | push @plan, { email => $email, name => $name, role => $role, outcome => 'error', detail => 'duplicate row in CSV' }; | |
| 612 | next; | |
| 613 | } | |
| 614 | ||
| 615 | my $e = $email; $e =~ s/'/\\'/g; | |
| 616 | my $existing = $db->db_readwrite($dbh, qq~SELECT id, account_status FROM `${database_name}`.users WHERE email='$e' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 617 | if($existing && $existing->{id}){ | |
| 618 | push @plan, { | |
| 619 | email => $email, name => $name, role => $role, | |
| 620 | outcome => 'already_member', | |
| 621 | detail => 'already in workspace (status: ' . $existing->{account_status} . ')', | |
| 622 | }; | |
| 623 | }else{ | |
| 624 | push @plan, { | |
| 625 | email => $email, name => $name, role => $role, | |
| 626 | outcome => 'will_invite', | |
| 627 | detail => 'new invite', | |
| 628 | }; | |
| 629 | } | |
| 630 | } | |
| 631 | return @plan; | |
| 632 | } | |
| 633 | ||
| 634 | sub imp_read_csv_input{ | |
| 635 | my $csv_text = ''; | |
| 636 | if(my $fh = $query->upload('csv_file')){ | |
| 637 | local $/; | |
| 638 | $csv_text = <$fh>; | |
| 639 | } | |
| 640 | $csv_text = $rawform{csv_text} if !$csv_text && $rawform{csv_text}; | |
| 641 | return $csv_text; | |
| 642 | } | |
| 643 | ||
| 644 | sub do_preview{ | |
| 645 | my $dbh = $db->db_connect(); | |
| 646 | my $csv_text = &imp_read_csv_input; | |
| 647 | if(length($csv_text) <= 5){ | |
| 648 | $db->db_disconnect($dbh); | |
| 649 | &render_form('CSV was empty.'); | |
| 650 | exit; | |
| 651 | } | |
| 652 | my @plan = &imp_build_plan($dbh, $csv_text); | |
| 653 | $db->db_disconnect($dbh); | |
| 654 | &render_preview(\@plan, $csv_text); | |
| 655 | } | |
| 656 | ||
| 657 | sub do_commit{ | |
| 658 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 659 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 660 | ||
| 661 | my $dbh = $db->db_connect(); | |
| 662 | my $csv_text = &imp_read_csv_input; | |
| 663 | if(length($csv_text) <= 5){ | |
| 664 | $db->db_disconnect($dbh); | |
| 665 | &render_form('CSV was empty.'); | |
| 666 | exit; | |
| 667 | } | |
| 668 | my @plan = &imp_build_plan($dbh, $csv_text); | |
| 669 | ||
| 670 | my @results; | |
| 671 | foreach my $p(@plan){ | |
| 672 | if($p->{outcome} eq 'error' || $p->{outcome} eq 'already_member'){ | |
| 673 | push @results, { %$p, claim_url => '' }; | |
| 674 | next; | |
| 675 | } | |
| 676 | if(!MODS::PTMatrix::PM::can_add_user($cid)){ | |
| 677 | push @results, { %$p, outcome => 'error', detail => 'workspace at user cap', claim_url => '' }; | |
| 678 | next; | |
| 679 | } | |
| 680 | ||
| 681 | my($u_id, $token) = $login->invite_user($cid, $p->{email}, $p->{name}, $p->{role}, $uid); | |
| 682 | if($u_id && $token){ | |
| 683 | MODS::PTMatrix::PM::audit($cid, $uid, 'user.invite_bulk', 'user', $u_id); | |
| 684 | my $link = 'https://ptmatrix.3dshawn.com/accept_invite.cgi?token=' . $token; | |
| 685 | push @results, { | |
| 686 | %$p, | |
| 687 | outcome => 'invited', | |
| 688 | detail => 'invite created', | |
| 689 | user_id => $u_id, | |
| 690 | claim_url => $link, | |
| 691 | }; | |
| 692 | ||
| 693 | my $mailer = MODS::PTMatrix::Mailer->new; | |
| 694 | my $nm = $p->{name} || $p->{email}; | |
| 695 | my $cn = $userinfo->{company_name} || 'your team'; | |
| 696 | my $body = qq~<p>Hi $nm,</p> | |
| 697 | <p>$userinfo->{display_name} ($userinfo->{email}) invited you to join <strong>$cn</strong> on PTMatrix.</p> | |
| 698 | <p><a href="$link" style="display:inline-block;background:#5aa9ff;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Accept invitation</a></p> | |
| 699 | <p>Or copy this link:<br><code>$link</code></p> | |
| 700 | <p style="color:#888;font-size:12px">This link is good for 14 days.</p>~; | |
| 701 | eval { | |
| 702 | $mailer->send( | |
| 703 | to => $p->{email}, | |
| 704 | subject => "$userinfo->{display_name} invited you to $cn on PTMatrix", | |
| 705 | body_html => $body, | |
| 706 | ); | |
| 707 | }; | |
| 708 | }else{ | |
| 709 | push @results, { %$p, outcome => 'error', detail => 'invite_user returned no token (likely email already exists)', claim_url => '' }; | |
| 710 | } | |
| 711 | } | |
| 712 | ||
| 713 | $db->db_disconnect($dbh); | |
| 714 | ||
| 715 | print qq~Content-Type: text/csv\r\nContent-Disposition: attachment; filename=ptmatrix_team_invite_results.csv\r\nCache-Control: no-store\r\n\r\n~; | |
| 716 | print "email,display_name,role,outcome,detail,user_id,claim_url\n"; | |
| 717 | foreach my $r(@results){ | |
| 718 | my @cells = ( | |
| 719 | $r->{email} // '', | |
| 720 | $r->{name} // '', | |
| 721 | $r->{role} // '', | |
| 722 | $r->{outcome} // '', | |
| 723 | $r->{detail} // '', | |
| 724 | $r->{user_id} // '', | |
| 725 | $r->{claim_url} // '', | |
| 726 | ); | |
| 727 | print join(',', map { &imp_csv_cell($_) } @cells), "\n"; | |
| 728 | } | |
| 729 | } | |
| 730 | ||
| 731 | sub render_form{ | |
| 732 | my($err) = @_; | |
| 733 | print qq~Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n~; | |
| 734 | my $err_html = $err ? qq~<div style="padding:10px 14px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;color:#fca5a5;font-size:13px;margin-bottom:18px">$err</div>~ : ''; | |
| 735 | my $body = qq~ | |
| 736 | <style> | |
| 737 | .imp-textarea { color:#e2e8f0 !important; font-family:ui-monospace,Consolas,Menlo,monospace; font-size:13px; } | |
| 738 | .imp-textarea::placeholder { color:#7a8696; opacity:1; } | |
| 739 | </style> | |
| 740 | <div class="page-pad"> | |
| 741 | <a href="/team.cgi" style="color:#94a3b8;font-size:13px;text-decoration:none">← Team</a> | |
| 742 | <h1 style="margin:8px 0 6px;font-size:24px;color:#fff">Bulk invite team members</h1> | |
| 743 | <p style="color:#cbd5e1;margin:0 0 24px;font-size:14px">Drop a CSV. We’ll create invitations, send emails (best-effort), and hand you back a results CSV with claim links you can share manually.</p> | |
| 744 | $err_html | |
| 745 | <form method="POST" action="/team_import.cgi" enctype="multipart/form-data"> | |
| 746 | <input type="hidden" name="act" value="preview"> | |
| 747 | <div style="margin-bottom:18px"> | |
| 748 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">CSV file</label> | |
| 749 | <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"> | |
| 750 | <label for="ti_file" style="display:inline-flex;align-items:center;gap:8px;padding:9px 16px;background:#2a313d;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#e2e8f0;font-size:13px;font-weight:500;cursor:pointer"> | |
| 751 | <span style="font-size:14px">📎</span> Choose CSV file | |
| 752 | </label> | |
| 753 | <input id="ti_file" type="file" name="csv_file" accept=".csv,text/csv" | |
| 754 | style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0)" | |
| 755 | onchange="document.getElementById('ti_fname').textContent=this.files.length?this.files[0].name:'No file chosen'"> | |
| 756 | <span id="ti_fname" style="font-size:13px;color:#94a3b8;font-style:italic">No file chosen</span> | |
| 757 | </div> | |
| 758 | <div style="font-size:12px;color:#94a3b8;margin-top:8px">Header row required. Columns: <code style="color:#a5b4fc">email</code> (required), <code style="color:#a5b4fc">display_name</code>, <code style="color:#a5b4fc">role</code> (member / observer / manager / company_admin).</div> | |
| 759 | </div> | |
| 760 | <div style="margin-bottom:24px"> | |
| 761 | <label style="display:block;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:#cbd5e1;margin-bottom:6px">Or paste CSV directly</label> | |
| 762 | <textarea class="input imp-textarea" name="csv_text" rows="6" placeholder="email,display_name,role alex\@example.com,Alex Lim,member sam\@example.com,Sam Tran,manager ..."></textarea> | |
| 763 | </div> | |
| 764 | <button type="submit" class="btn btn-primary btn-lg">Preview invites</button> | |
| 765 | <p style="font-size:12px;color:#94a3b8;margin:12px 0 0">No DB writes happen until you confirm on the next screen.</p> | |
| 766 | </form> | |
| 767 | </div>~; | |
| 768 | $load->render({ | |
| 769 | userinfo => $userinfo, | |
| 770 | page_key => 'team', | |
| 771 | title => 'Bulk invite team members', | |
| 772 | body => $body, | |
| 773 | }); | |
| 774 | } | |
| 775 | ||
| 776 | sub render_preview{ | |
| 777 | my($plan, $csv_text) = @_; | |
| 778 | print qq~Content-Type: text/html; charset=utf-8\r\nCache-Control: no-store\r\n\r\n~; | |
| 779 | ||
| 780 | my $n_invite = scalar(grep { $_->{outcome} eq 'will_invite' } @$plan); | |
| 781 | my $n_member = scalar(grep { $_->{outcome} eq 'already_member' } @$plan); | |
| 782 | my $n_error = scalar(grep { $_->{outcome} eq 'error' } @$plan); | |
| 783 | my $total = scalar @$plan; | |
| 784 | ||
| 785 | my %label = ( | |
| 786 | will_invite => ['Invite', '#86efac', 'rgba(34,197,94,.15)'], | |
| 787 | already_member => ['Skip', '#94a3b8', 'rgba(148,163,184,.12)'], | |
| 788 | error => ['Error', '#fca5a5', 'rgba(239,68,68,.15)'], | |
| 789 | ); | |
| 790 | ||
| 791 | my $rows_html = ''; | |
| 792 | foreach my $p(@$plan){ | |
| 793 | my($lbl, $col, $bg) = @{ $label{$p->{outcome}} || ['?', '#fff', 'rgba(255,255,255,.1)'] }; | |
| 794 | my $email = &imp_h($p->{email}); | |
| 795 | my $name = &imp_h($p->{name} || ''); | |
| 796 | my $role = &imp_h($p->{role} || ''); | |
| 797 | my $det = &imp_h($p->{detail} || ''); | |
| 798 | $rows_html .= qq~ | |
| 799 | <tr style="border-top:1px solid rgba(255,255,255,.05)"> | |
| 800 | <td style="padding:8px 12px;font-size:13px">$email</td> | |
| 801 | <td style="padding:8px 12px;font-size:13px;color:#aaa">$name</td> | |
| 802 | <td style="padding:8px 12px;font-size:12px;color:#cbd5e1">$role</td> | |
| 803 | <td style="padding:8px 12px"><span style="font-size:11px;padding:2px 8px;border-radius:4px;color:$col;background:$bg">$lbl</span></td> | |
| 804 | <td style="padding:8px 12px;font-size:12px;color:#94a3b8">$det</td> | |
| 805 | </tr>~; | |
| 806 | } | |
| 807 | my $csv_esc = &imp_h($csv_text); | |
| 808 | my $commit_disabled = ($n_invite == 0) ? 'disabled' : ''; | |
| 809 | ||
| 810 | my $body = qq~ | |
| 811 | <div class="page-pad"> | |
| 812 | <a href="/team_import.cgi" style="color:#94a3b8;font-size:13px;text-decoration:none">← Back</a> | |
| 813 | <h1 style="margin:8px 0 6px;font-size:24px;color:#fff">Preview invitations</h1> | |
| 814 | <p style="color:#94a3af;margin:0 0 24px;font-size:14px">$total row(s) parsed. Nothing has been written yet.</p> | |
| 815 | <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:24px"> | |
| 816 | <div style="padding:14px;border:1px solid rgba(34,197,94,.25);background:rgba(34,197,94,.05);border-radius:10px"><div style="color:#86efac;font-size:11px;text-transform:uppercase;letter-spacing:.05em">Invite</div><div style="font-size:24px;font-weight:700;color:#fff">$n_invite</div></div> | |
| 817 | <div style="padding:14px;border:1px solid rgba(148,163,184,.25);background:rgba(148,163,184,.04);border-radius:10px"><div style="color:#94a3b8;font-size:11px;text-transform:uppercase;letter-spacing:.05em">Already in workspace</div><div style="font-size:24px;font-weight:700;color:#fff">$n_member</div></div> | |
| 818 | <div style="padding:14px;border:1px solid rgba(239,68,68,.25);background:rgba(239,68,68,.05);border-radius:10px"><div style="color:#fca5a5;font-size:11px;text-transform:uppercase;letter-spacing:.05em">Errors</div><div style="font-size:24px;font-weight:700;color:#fff">$n_error</div></div> | |
| 819 | </div> | |
| 820 | <div style="border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:rgba(20,20,30,.4);margin-bottom:24px"> | |
| 821 | <table style="width:100%;border-collapse:collapse"> | |
| 822 | <thead><tr><th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Email</th><th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Display name</th><th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Role</th><th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Outcome</th><th style="padding:10px 12px;text-align:left;font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em">Detail</th></tr></thead> | |
| 823 | <tbody>$rows_html</tbody> | |
| 824 | </table> | |
| 825 | </div> | |
| 826 | <form method="POST" action="/team_import.cgi"> | |
| 827 | <input type="hidden" name="act" value="commit"> | |
| 828 | <textarea name="csv_text" style="display:none">$csv_esc</textarea> | |
| 829 | <button type="submit" class="btn btn-primary btn-lg" $commit_disabled>Send $n_invite invitation(s) & download CSV</button> | |
| 830 | <a href="/team_import.cgi" style="margin-left:14px;color:#94a3b8;font-size:13px;text-decoration:none">Cancel</a> | |
| 831 | </form> | |
| 832 | </div>~; | |
| 833 | $load->render({ | |
| 834 | userinfo => $userinfo, | |
| 835 | page_key => 'team', | |
| 836 | title => 'Preview invitations', | |
| 837 | body => $body, | |
| 838 | }); | |
| 839 | } |