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

O Operator
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
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##################################################################################################################################
4# ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____
5# / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \
6# / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / /
7# / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ /
8# \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/
9#
10# - Written by: Shawn Pickering
11# - shawn@shawnpickering.com
12##################################################################################################################################
13use strict;
14use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com';
15
16
17#############################################################
18## Variables
19#############################################################
20
21
22
23############################################################
24# Modules
25############################################################
26use CGI; my $query = new CGI; my $form = $query->Vars;
27use MODS::Login; my $login = new MODS::Login;
28use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls();
29use MODS::Template; my $tfile = new MODS::Template;
30use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper;
31use MODS::DBConnect; my $db = new MODS::DBConnect;
32use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name');
33use MODS::PTMatrix::PM;
34use 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
42my %rawform = %$form;
43foreach 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;
54my $userinfo = $login->login_verify();
55my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'team';
56
57if(!$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
66if($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############################################################
101sub 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
174sub bounce{
175 my($target) = @_;
176 print qq~Status: 302 Found\nLocation: $target\n\n~;
177 exit;
178}
179
180sub 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
212sub 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
228sub 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
240sub 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#============================================================
263sub 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
268sub 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
279sub 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
291sub 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
334sub 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">&larr; 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> &middot; $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 &amp; 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 &amp; 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> &middot;
489 Permission group: <span style="color:#e5e7eb">$perm_grp</span> &middot;
490 Auth: <span style="color:#e5e7eb">$auth</span>~ . ($member->{two_factor_enabled} ? ' <span style="color:#22c55e">+ 2FA</span>' : '') . qq~ &middot;
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#============================================================
533sub imp_h{
534 my $s = shift // '';
535 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
536 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
537 return $s;
538}
539
540sub imp_csv_cell{
541 my $s = shift // '';
542 if($s =~ /[",\n]/){$s =~ s/"/""/g; return qq~"$s"~;}
543 return $s;
544}
545
546sub 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
567sub 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
590sub 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
634sub 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
644sub 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
657sub 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
731sub 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">&larr; 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&rsquo;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">&#128206;</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&#10;alex\@example.com,Alex Lim,member&#10;sam\@example.com,Sam Tran,manager&#10;..."></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
776sub 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">&larr; 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) &amp; 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}