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

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

added on local at 2026-07-01 15:03:06

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