ChatGPT's update to harden software and run db updates.

This commit is contained in:
2026-10-21 06:53:45 -04:00
parent 1e63076123
commit acc8cb70f5
171 changed files with 9325 additions and 404 deletions
+2 -1
View File
@@ -3,12 +3,13 @@
class AdminAccounts extends Base {
public function create(array $input): array {
(new TenantAccess())->requireSuper();
$first = trim($input['user_first_name'] ?? '');
$last = trim($input['user_last_name'] ?? '');
$email = trim($input['user_email'] ?? '');
$name = trim($input['user_name'] ?? '');
$role = $input['user_role'] ?? '';
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !in_array($role, ['User', 'Guest', 'Administrator'], true))
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !in_array($role, ['User', 'Guest', 'Administrator', TenantAccess::SUPER], true))
throw new AppException('Enter a valid username, first/last name, email and role.');
$db = $this->connect();
$stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (?,?) OR user_email IN (?,?)');
+8 -21
View File
@@ -69,15 +69,8 @@ class Alerts extends Base {
case "add":
$alert_title = filter_input(INPUT_POST, "alert_title") ?: "";
$alert_starts_date = filter_input(INPUT_POST, "alert_starts_date") ?: "";
$alert_starts_time = filter_input(INPUT_POST, "alert_starts_time") ?: "";
$alert_expires_date = filter_input(INPUT_POST, "alert_expires_date") ?: "";
$alert_expires_time = filter_input(INPUT_POST, "alert_expires_time") ?: "";
$alert_message = filter_input(INPUT_POST, "alert_message") ?: "";
$alert_starts = date("Y-m-d H:i", strtotime("{$alert_starts_date} {$alert_starts_time}"));
$alert_expires = date("Y-m-d H:i", strtotime("{$alert_expires_date} {$alert_expires_time}"));
$data=MaintenanceValidation::alert($_POST);
$alert_title=$data['title'];$alert_starts=$data['starts'];$alert_expires=$data['expires'];$alert_message=$data['message'];
$SQL = "insert into alerts
@@ -120,7 +113,7 @@ class Alerts extends Base {
public function deleteAlert() {
$alert_serial = filter_input(INPUT_POST, "alert_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$alert = $this->getAlert($alert_serial);
@@ -146,7 +139,7 @@ class Alerts extends Base {
case "prompt":
$alert_serial = filter_input(INPUT_POST, "alert_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$alert = $this->getAlert($alert_serial);
@@ -162,16 +155,9 @@ class Alerts extends Base {
case "update":
$alert_serial = filter_input(INPUT_POST, "alert_serial") ?: 0;
$alert_title = filter_input(INPUT_POST, "alert_title") ?: "";
$alert_starts_date = filter_input(INPUT_POST, "alert_starts_date") ?: "";
$alert_starts_time = filter_input(INPUT_POST, "alert_starts_time") ?: "";
$alert_expires_date = filter_input(INPUT_POST, "alert_expires_date") ?: "";
$alert_expires_time = filter_input(INPUT_POST, "alert_expires_time") ?: "";
$alert_message = filter_input(INPUT_POST, "alert_message") ?: "";
$alert_starts = date("Y-m-d H:i", strtotime("{$alert_starts_date} {$alert_starts_time}"));
$alert_expires = date("Y-m-d H:i", strtotime("{$alert_expires_date} {$alert_expires_time}"));
$alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$data=MaintenanceValidation::alert($_POST);
$alert_title=$data['title'];$alert_starts=$data['starts'];$alert_expires=$data['expires'];$alert_message=$data['message'];
$alert = $this->getAlert($alert_serial);
@@ -211,6 +197,7 @@ class Alerts extends Base {
// =====================
public function getAlert($alert_serial = 0) {
$alert_serial=MaintenanceService::id($alert_serial);
$SQL = "select *,
date_format(alert_started, '%Y') as alert_started_year,
+22 -2
View File
@@ -4,6 +4,7 @@
class AuthService extends Base {
public const IDLE_TIMEOUT = 1800;
public const ABSOLUTE_TIMEOUT = 28800;
public function findUser(string $identifier): ?array {
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1');
@@ -50,6 +51,8 @@ class AuthService extends Base {
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]);
}
session_regenerate_id(true);
unset($_SESSION['mfa_user'],$_SESSION['recent_auth'],$_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);
$_SESSION['authenticated_at']=time();
$_SESSION[self::USER_AUTHENTICATED] = true;
$_SESSION[self::USER_SERIAL] = (int) $user['user_serial'];
$_SESSION[self::USER_NAME] = $user['user_name'];
@@ -68,7 +71,7 @@ class AuthService extends Base {
public function currentUser(): ?array {
if (empty($_SESSION[self::USER_AUTHENTICATED]))
return null;
if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT)
if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT || time() - ($_SESSION['authenticated_at'] ?? 0) >= self::ABSOLUTE_TIMEOUT)
return null;
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id');
$stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]);
@@ -104,8 +107,25 @@ class AuthService extends Base {
if ($stmt->fetch())
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
$stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')");
$kind=$input['registration_kind']??'account';
if(!in_array($kind,['account','new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.');
$clubName=trim($input['club_name']??'');
if($kind==='new_club' && ($clubName===''||strlen($clubName)>128))throw new InvalidArgumentException('Enter your new club name (up to 128 bytes).');
try {
$stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]);
DatabaseTransaction::run($db,function(PDO $db)use($stmt,$name,$first,$last,$email,$input,$kind,$clubName){
if($kind==='invited'){
$invites=$db->prepare("SELECT i.club_serial,i.invited_by,i.membership_role FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial WHERE i.invite_email=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active'");$invites->execute([$email]);$valid=false;$access=new TenantAccess();
foreach($invites->fetchAll() as $invite)if($access->canManage((int)$invite['club_serial'],$db,(int)$invite['invited_by'])&&($invite['membership_role']==='member'||$access->isSuper($db,(int)$invite['invited_by']))){$valid=true;break;}
if(!$valid)throw new InvalidArgumentException('Ask your club administrator for a current invitation to this email address.');
}
$stmt->execute([':name'=>$name,':first'=>$first,':last'=>$last,':email'=>$email,':hash'=>password_hash($input['user_password'],PASSWORD_DEFAULT)]);
$user=(int)$db->lastInsertId();
if($kind==='new_club'){
$club=$db->prepare("INSERT INTO clubs(club_name,club_status) VALUES(?,'active')");$club->execute([$clubName]);$clubId=(int)$db->lastInsertId();
$membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,membership_status,joined_on) VALUES(?,?,'administrator','active',?)");$membership->execute([$clubId,$user,date('Y-m-d')]);
Audit::required($db,$user,'registered new club '.json_encode(['club'=>$clubId,'name'=>$clubName]));
}
});
} catch (PDOException $e) {
if ($e->getCode() === '23000')
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
+14
View File
@@ -0,0 +1,14 @@
<?php
final class BackupValidation {
/** mariadb-dump writes the completion comment at the end; never load the dump. */
public static function complete(string $path): bool {
clearstatcache(true,$path);
if(!is_file($path)||filesize($path)<100)return false;
$stream=fopen($path,'rb');if(!$stream)return false;
try{
if(fseek($stream,max(0,filesize($path)-8192),SEEK_SET)!==0)return false;
$tail=fread($stream,8192);
return $tail!==false&&preg_match('/(?:\A|\r?\n)-- Dump completed on [^\r\n]+(?:\r?\n)*\z/',$tail)===1;
}finally{fclose($stream);}
}
}
+28 -10
View File
@@ -15,7 +15,7 @@ class Base {
const APPLICATION_NAME = "My Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.00";
const VERSION = "1.04";
const TIMEZONE = "America/New_York";
// =================
// Session Variables
@@ -176,7 +176,7 @@ class Base {
public function displayContent($content = "") {
$html = $this->applyXSL("", $this->getXSL("content"));
$html = $this->applyXSL("", $this->getXSL("content"), ["IS_SUPER"=>(new TenantAccess())->isSuper()?"1":"0","CAN_MANAGE_CLUBS" => (new TenantAccess())->managesAny()?"1":"0"]);
$html = str_replace("$$$-CONTENT-$$$", $content, $html);
echo $html;
@@ -245,11 +245,18 @@ class Base {
exit('Method not allowed.');
}
// Reject arrays and oversized values before passing input to string operations.
foreach (array_merge($_GET, $_POST) as $value) {
if (!is_string($value) || strlen($value) > 8192) {
http_response_code(400);
exit('Invalid request.');
// Ticket bodies need room for 8,000 four-byte Unicode characters. Keep
// a hard transport ceiling; field validators enforce the character limit.
$candidate=$_POST['action']??$_GET['Xaction']??$_GET['action']??'';
$ticketSubmission=is_string($candidate)&&strtolower(trim($candidate))==='roster.tickets'
&& in_array($_POST['step']??null,['openTicket','reply'],true);
foreach ([$_GET,$_POST] as $source=>$values) {
foreach($values as $field=>$value){
$byteLimit=$source===1&&$ticketSubmission&&$field==='message'?65536:8192;
if (!is_string($value) || strlen($value) > $byteLimit) {
http_response_code(400);
exit('Invalid request.');
}
}
}
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
@@ -296,6 +303,12 @@ class Base {
if ($method === 'POST') {
$this->requireCsrf();
}
$mfa=new MfaService();
if($mfa->gate($user,$action))return;
$sensitive=in_array($action,['users.changepassword','users.adduser','users.edituser','users.deleteuser','users.updateactivestatus','users.useractive','users.resetpassword'],true)
|| ($action==='roster.membersummary' && ($_POST['step']??'')==='member')
|| ($action==='roster.workspace' && (($_POST['step']??'')==='member' || (($_POST['step']??'')==='invite' && ($_POST['membership_role']??'')==='administrator')));
if($method==='POST' && $sensitive && (new TenantAccess())->managesAny())$mfa->requireRecent();
if ((bool) $user['user_change_password']) {
(new Users())->changePassword();
return;
@@ -312,7 +325,12 @@ class Base {
'getage' => [Base::class, 'getAge', false],
'users.changepassword' => [Users::class, 'changePassword', false],
'roster.workspace' => [Roster::class,'workspace',false],
'roster.membersummary' => [MemberSummary::class,'show',false],
'roster.createclub' => [Roster::class,'createClub','super'],
'roster.help' => [Roster::class,'help',false],
'roster.invitations' => [Roster::class,'invitations',false],
'roster.tickets' => [Roster::class,'tickets',false],
'rosterreports.catalog' => [RosterReports::class,'catalog',true],
'rosterreports.ledger' => [RosterReports::class,'ledger',true],
'rosterreports.csv' => [RosterReports::class,'csv',true],
'rosterreports.excel' => [RosterReports::class,'excel',true],
@@ -334,7 +352,7 @@ class Base {
];
foreach ($adminRoutes as $class => $methods) {
foreach ($methods as $entry) {
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, $class==='Reports'?true:'super'];
}
}
if (!isset($routes[$action])) {
@@ -342,11 +360,11 @@ class Base {
exit('Page not found.');
}
[$class, $entry, $adminOnly] = $routes[$action];
if ($adminOnly && !in_array($user['user_role'], ['Administrator', 'System Administrator'], true)) {
if (($adminOnly==='super' && !(new TenantAccess())->isSuper()) || ($adminOnly===true && !(new TenantAccess())->managesAny())) {
http_response_code(403);
exit('You do not have permission to perform this action.');
}
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.help'], true)) {
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.membersummary','roster.createclub','roster.help','roster.invitations','roster.tickets','rosterreports.catalog'], true)) {
http_response_code(405);
exit('Use POST for this action.');
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/** Same authorized query feeds the count and bounded rows. */
final class BoundedList {
public static array $pages=[];
public static bool $exporting=false;
public static function page(string $scope): int {
$raw=$_GET[$scope.'_page']??'1';$page=filter_var($raw,FILTER_VALIDATE_INT,['options'=>['min_range'=>1]]);
if($page===false)throw new AppException('Choose a valid page.');return $page;
}
public static function rows(PDO $db,string $sql,array $args,?int $page,string $scope): array {
if($page!==null){
$stmt=$db->prepare('SELECT COUNT(*) FROM ('.$sql.') scoped_rows');$stmt->execute($args);$count=(int)$stmt->fetchColumn();
$paging=Pagination::calculate($count,25,$page);self::$pages[$scope]=$paging+['count'=>$count,'pages'=>max(1,(int)ceil($count/25))];
$sql.=' LIMIT '.$paging['size'].' OFFSET '.$paging['offset'];
}
if($page===null&&self::$exporting)$sql.=' LIMIT '.(self::exportLimit()+1);
$stmt=$db->prepare($sql);$stmt->execute($args);$rows=$stmt->fetchAll();
if(self::$exporting&&count($rows)>self::exportLimit())throw new AppException('This export exceeds the configured row limit.',422);
return $rows;
}
public static function navigation(string $scope,?string $action=null): string {
$p=self::$pages[$scope]??null;if(!$p)return '';
$h=static fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES,'UTF-8');$html='<nav aria-label="'.$h($scope).' pages"><p>'.$p['count'].' records. Page '.$p['page'].' of '.$p['pages'].'.</p>';
foreach(['First'=>1,'Previous'=>$p['page']-1,'Next'=>$p['page']+1,'Last'=>$p['pages']] as $label=>$page){
if($page<1||$page>$p['pages']||$page===$p['page'])continue;
$args=$_GET;unset($args['action']);$args['Xaction']=$action??$args['Xaction']??'Roster.workspace';$args[$scope.'_page']=$page;
$html.='<a class="btn btn-outline-primary me-2" href="'.$h('main.php?'.http_build_query($args)).'">'.$label.'</a>';
}
return $html.'</nav>';
}
public static function exportLimit(): int {
$value=getenv('HCR_EXPORT_MAX_ROWS');$limit=$value===false?1000:filter_var($value,FILTER_VALIDATE_INT,['options'=>['min_range'=>1,'max_range'=>5000]]);
if($limit===false)throw new RuntimeException('Invalid export row limit.');return $limit;
}
public static function exportGuard(PDO $db,string $sql,array $args): void {
$stmt=$db->prepare($sql);$stmt->execute($args);
if((int)$stmt->fetchColumn()>self::exportLimit())throw new AppException('This export exceeds the configured row limit. Contact support for a large export.',422);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/** Move already-authorized club forms out of summary tables into labelled dialogs. */
final class ClubFormModals {
public static function render(string $html): string {
$titles=['club'=>'Edit or archive club','member'=>'Add member','invite'=>'Invite members',
'season'=>'Add season','charge'=>'Record season dues','payment'=>'Record payment',
'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation'];
$doc=new DOMDocument('1.0','UTF-8');$previous=libxml_use_internal_errors(true);
try{$loaded=$doc->loadHTML('<?xml encoding="UTF-8">'.$html,LIBXML_NONET);}
finally{libxml_clear_errors();libxml_use_internal_errors($previous);}
if(!$loaded)throw new RuntimeException('Cannot render club dialogs.');
$main=$doc->getElementsByTagName('main')->item(0);
if(!$main)throw new LogicException('Club summary container is missing.');
$dialogs=[];$index=0;
foreach(iterator_to_array($main->getElementsByTagName('form')) as $form){
$values=[];foreach($form->getElementsByTagName('input') as $input)$values[$input->getAttribute('name')]=$input->getAttribute('value');
$step=$values['step']??'';
if(!in_array($values['action']??'',['Roster.workspace','Roster.memberSummary'],true)||!isset($titles[$step]))continue;
$title=$step==='member'&&!empty($values['membership_serial'])?'Edit membership':$titles[$step];
$id='club-action-'.(++$index);$parent=$form->parentNode;
$trigger=$doc->createElement('button');$trigger->appendChild($doc->createTextNode($title));
foreach(['type'=>'button','class'=>'btn btn-outline-primary me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value);
$dialog=$doc->createElement('div');
foreach(['class'=>'modal fade club-action-modal','id'=>$id,'tabindex'=>'-1','aria-labelledby'=>$id.'-title','aria-hidden'=>'true'] as $key=>$value)$dialog->setAttribute($key,$value);
$layout=$dialog->appendChild($doc->createElement('div'));$layout->setAttribute('class','modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable');
$content=$layout->appendChild($doc->createElement('div'));$content->setAttribute('class','modal-content');
$header=$content->appendChild($doc->createElement('div'));$header->setAttribute('class','modal-header');
$heading=$header->appendChild($doc->createElement('h2'));$heading->setAttribute('class','modal-title fs-5');$heading->setAttribute('id',$id.'-title');$heading->appendChild($doc->createTextNode($title));
$close=$header->appendChild($doc->createElement('button'));$close->setAttribute('type','button');$close->setAttribute('class','btn-close');$close->setAttribute('data-bs-dismiss','modal');$close->setAttribute('aria-label','Close');
$body=$content->appendChild($doc->createElement('div'));$body->setAttribute('class','modal-body');
// Keep the selected row visible in the dialog so financial/member actions are unambiguous.
$row=$parent;while($row&&$row!==$main&&!in_array($row->nodeName,['tr','li'],true))$row=$row->parentNode;
if($row&&$row!==$main){
$text=$row->textContent;
if($row->nodeName==='tr')$text=implode(' · ',array_map(static fn($cell)=>trim($cell->textContent),array_slice(iterator_to_array($row->getElementsByTagName('td')),0,-1)));
$context=$body->appendChild($doc->createElement('p'));$context->setAttribute('class','text-secondary');$context->appendChild($doc->createTextNode(trim(preg_replace('/\s+/u',' ',$text))));
}
if($parent->nodeName==='details'){
$parent->parentNode->replaceChild($trigger,$parent);
foreach(iterator_to_array($parent->childNodes) as $child)if($child->nodeName!=='summary')$body->appendChild($child);
}else{
$previous=$form->previousSibling;
if($previous&&$previous->nodeName==='h4')$previous->parentNode->removeChild($previous);
$following=$form->nextSibling;
$parent->replaceChild($trigger,$form);$body->appendChild($form);
if($following&&$following->nodeName==='p')$body->appendChild($following);
}
$error=$doc->createElement('p');$error->setAttribute('class','alert alert-danger club-modal-error');$error->setAttribute('role','alert');$error->setAttribute('tabindex','-1');$error->setAttribute('hidden','hidden');$body->insertBefore($error,$body->firstChild);
$footer=$content->appendChild($doc->createElement('div'));$footer->setAttribute('class','modal-footer');
$cancel=$footer->appendChild($doc->createElement('button'));$cancel->setAttribute('type','button');$cancel->setAttribute('class','btn btn-outline-secondary');$cancel->setAttribute('data-bs-dismiss','modal');$cancel->appendChild($doc->createTextNode('Cancel'));
$dialogs[]=$dialog;
if($step==='invite')foreach(iterator_to_array($main->getElementsByTagName('a')) as $link)if($link->getAttribute('href')==='#club-invitations'){$link->parentNode->replaceChild($trigger,$link);break;}
}
foreach($dialogs as $dialog)$main->appendChild($dialog);
$script=$main->appendChild($doc->createElement('script'));$script->setAttribute('src','js/club-modals.js?version='.Base::VERSION);
return $doc->saveHTML($main);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/** Invitations grant only club membership, never global account privileges. */
class ClubInvitations extends RosterService {
public function pending(?int $page=null): array {
return BoundedList::rows($this->connect(),"SELECT i.invitation_serial,c.club_name,i.membership_role,i.expires_at FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial JOIN users u ON LOWER(u.user_email)=LOWER(i.invite_email) WHERE u.user_serial=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active' ORDER BY i.invitation_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'pending');
}
public function forClub(int $club,?int $page=null): array {
(new TenantAccess())->requireManager($club);$this->club($club);
return BoundedList::rows($this->connect(),'SELECT invitation_serial,invite_email,membership_role,invitation_status,expires_at FROM club_invitations WHERE club_serial=? ORDER BY invitation_serial DESC',[$club],$page,'invitations');
}
public function invite(int $club,string $email,string $role='member'): array {
$email=trim($email);$access=new TenantAccess();$access->requireManager($club);
if(!filter_var($email,FILTER_VALIDATE_EMAIL)||strlen($email)>100||!in_array($role,['member','administrator'],true))throw new AppException('Enter a valid invitation email and club role.');
if($role==='administrator')$access->requireSuper();
if(!(new AuthService())->allowAttempt('club-invite',($_SESSION[self::USER_SERIAL]??0).':'.$club,20,3600))throw new AppException('Too many invitations. Try again later.',429);
(new ClubResources())->consume($club,'email');
$db=$this->connect();
$selected=DatabaseTransaction::run($db,function(PDO $db)use($club,$email,$role,$access){
$selected=$this->club($club,$db,true);
if($role==='administrator')$access->requireSuper($db);
$stmt=$db->prepare('SELECT * FROM club_invitations WHERE club_serial=? AND invite_email=? FOR UPDATE');$stmt->execute([$club,$email]);$existing=$stmt->fetch();
if($existing && strtotime($existing['sent_at'].' UTC')>time()-60)throw new AppException('Wait a minute before sending this invitation again.',429);
if($existing && $existing['membership_role']==='administrator')$access->requireSuper($db);
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,membership_role,invited_by,expires_at,sent_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 7 DAY),UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE membership_role=VALUES(membership_role),invited_by=VALUES(invited_by),invitation_status='pending',expires_at=VALUES(expires_at),sent_at=VALUES(sent_at),accepted_at=NULL");
$stmt->execute([$club,$email,$role,$_SESSION[self::USER_SERIAL]]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'invited club member '.json_encode(['club'=>$club,'email'=>$email,'role'=>$role]));return $selected;
});
$sent=false;try{$sent=$this->deliver($email,$selected['club_name']);}catch(Throwable $e){error_log('Club invitation delivery failed: '.$e->getMessage());}
return ['ok'=>true,'message'=>'Invitation saved. The recipient must sign in with this email, verify it, and accept the invitation.','warning'=>$sent?'':'The invitation email could not be sent. The recipient can still sign in and open Invitations; you can retry sending after a minute.'];
}
protected function deliver(string $email,string $club): bool {
$url=rtrim((string)$this->getSettings()->ApplicationUrl,'/');
if(!filter_var($url,FILTER_VALIDATE_URL)||parse_url($url,PHP_URL_SCHEME)!=='https'||parse_url($url,PHP_URL_QUERY)!==null||parse_url($url,PHP_URL_FRAGMENT)!==null||parse_url($url,PHP_URL_USER)!==null||parse_url($url,PHP_URL_PASS)!==null)throw new RuntimeException('Configure a trusted HTTPS ApplicationUrl.');
$h=static fn($v)=>htmlspecialchars($v,ENT_QUOTES,'UTF-8');
$body='<p>You have been invited to '.$h($club).'.</p><p><a href="'.$h($url.'/main.php?Xaction=Roster.invitations').'">Sign in and open Invitations</a>. If you do not have an account, use <a href="'.$h($url.'/main.php?Xaction=signup&membership=invited').'">invited member signup</a> with this email and verify it. Accepting the invitation grants access to this club.</p><p>This invitation expires in seven days.</p>';
return (new Email())->sendEmail($email,self::APPLICATION_NAME.' - Club invitation',$body);
}
public function accept(int $id): void {
$db=$this->connect();
DatabaseTransaction::run($db,function(PDO $db)use($id){
// Match membership/account edits: lock the recipient before the club.
$stmt=$db->prepare('SELECT club_serial FROM club_invitations WHERE invitation_serial=?');$stmt->execute([$id]);$club=(int)$stmt->fetchColumn();
$stmt=$db->prepare('SELECT user_serial,user_email,user_email_verified_at,user_status FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);$user=$stmt->fetch();
$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('Invitation is unavailable.',409);
$stmt=$db->prepare("SELECT * FROM club_invitations WHERE invitation_serial=? AND invitation_status='pending' AND expires_at>UTC_TIMESTAMP() FOR UPDATE");$stmt->execute([$id]);$invite=$stmt->fetch();
if(!$invite||!$user||strcasecmp($invite['invite_email'],$user['user_email'])!==0)throw new AppException('Invitation is unavailable.',404);
if(!$user['user_email_verified_at']||$user['user_status']!=='active')throw new AppException('Verify your account email before accepting an invitation.',403);
$access=new TenantAccess();
if(!$access->canManage($club,$db,(int)$invite['invited_by']) || ($invite['membership_role']==='administrator'&&!$access->isSuper($db,(int)$invite['invited_by'])))throw new AppException('Ask a current club administrator to send a new invitation.',409);
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,'active',?,?) ON DUPLICATE KEY UPDATE membership_role=IF(membership_status='active' AND membership_role='administrator','administrator',VALUES(membership_role)),membership_status='active'");$stmt->execute([$club,$user['user_serial'],date('Y-m-d'),$invite['membership_role']]);
$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='accepted',accepted_at=UTC_TIMESTAMP() WHERE invitation_serial=?");$stmt->execute([$id]);
Audit::required($db,(int)$user['user_serial'],'accepted club invitation '.json_encode(['club'=>$club,'invitation'=>$id,'role'=>$invite['membership_role']]));
});
}
public function revoke(int $club,int $id): void {
$db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id){
$this->club($club,$db,true);
$stmt=$db->prepare('SELECT membership_role FROM club_invitations WHERE invitation_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$id,$club]);$role=$stmt->fetchColumn();if(!$role)throw new AppException('Invitation not found.',404);
if($role==='administrator')(new TenantAccess())->requireSuper($db);
$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE invitation_serial=? AND club_serial=? AND invitation_status='pending'");$stmt->execute([$id,$club]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'revoked club invitation '.json_encode(['club'=>$club,'invitation'=>$id]));
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/** Atomic shared club budgets, independent of the member who submits work. */
class ClubResources extends Base {
public function consume(int $club,string $resource): void {
$defaults=['email'=>100,'ticket'=>100,'reply'=>1000,'export'=>20];
if(!isset($defaults[$resource]))throw new LogicException('Unknown club resource.');
(new RosterService())->club($club);
$raw=getenv('HCR_CLUB_'.strtoupper($resource).'_HOURLY');
$limit=$raw===false?$defaults[$resource]:filter_var($raw,FILTER_VALIDATE_INT,['options'=>['min_range'=>1,'max_range'=>100000]]);
if($limit===false)throw new RuntimeException('Invalid club resource limit.');
$db=$this->connect();
$stmt=$db->prepare("INSERT INTO club_resource_usage(club_serial,resource_name,window_started,used_count) VALUES(?,?,UTC_TIMESTAMP(),LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE used_count=LAST_INSERT_ID(IF(window_started<=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 HOUR),1,LEAST(used_count+1,100001))),window_started=IF(window_started<=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 HOUR),UTC_TIMESTAMP(),window_started)");$stmt->execute([$club,$resource]);
$stmt=$db->query('SELECT LAST_INSERT_ID()');
if((int)$stmt->fetchColumn()>$limit)throw new AppException('This club has reached its hourly '.$resource.' limit. Try again later.',429);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/** Members see their own tickets; assigned club administrators see that club's tickets. */
class ClubTickets extends RosterService {
public const SUBJECT_LIMIT=200;
public const MESSAGE_LIMIT=8000;
private function text(string $value,int $limit,string $label): string {
if(!mb_check_encoding($value,'UTF-8')||str_contains($value,"\0"))throw new AppException($label.' must contain valid text.');
$value=trim($value);
if(preg_match('/\A[\s\p{Z}]*\z/u',$value))throw new AppException($label.' is required.');
if(mb_strlen($value,'UTF-8')>$limit)throw new AppException($label.' must be '.$limit.' characters or fewer.');
return $value;
}
public function normalizeSearch(string $search): string {
$search=trim($search);return $search===''?'':$this->text($search,100,'Ticket search');
}
public function page(int $club,int $page=1,string $search=''): array {
$this->club($club);if($page<1)throw new AppException('Choose a valid ticket page.');
$search=$this->normalizeSearch($search);$manage=$this->manages($club);$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
$scope=' WHERE club_serial=?'.($manage?'':' AND user_serial=?');
if($search!==''){$scope.=" AND subject LIKE ? ESCAPE '!'";$args[]='%'.str_replace(['!','%','_'],['!!','!%','!_'],$search).'%';}
$db=$this->connect();$stmt=$db->prepare('SELECT COUNT(*) FROM club_tickets'.$scope);$stmt->execute($args);$count=(int)$stmt->fetchColumn();
$paging=Pagination::calculate($count,25,$page);$pages=max(1,(int)ceil($count/$paging['size']));
$stmt=$db->prepare('SELECT ticket_serial,subject,ticket_status,user_serial,created_at FROM club_tickets'.$scope.' ORDER BY ticket_serial DESC LIMIT '.$paging['size'].' OFFSET '.$paging['offset']);$stmt->execute($args);
return $paging+['rows'=>$stmt->fetchAll(),'count'=>$count,'pages'=>$pages,'search'=>$search];
}
public function listing(int $club,int $page=1,string $search=''): array {return $this->page($club,$page,$search)['rows'];}
public function ticket(int $club,int $id,?PDO $db=null): array {
$this->club($club,$db);$stmt=($db??$this->connect())->prepare('SELECT * FROM club_tickets WHERE club_serial=? AND ticket_serial=?');$stmt->execute([$club,$id]);$row=$stmt->fetch();
if(!$row||(!$this->manages($club,$db)&&(int)$row['user_serial']!==(int)($_SESSION[self::USER_SERIAL]??0)))throw new AppException('Ticket not found.',404);return $row;
}
public function messages(int $club,int $id,?int $page=null): array {
$this->ticket($club,$id);return BoundedList::rows($this->connect(),'SELECT m.message_text,m.created_at,u.user_first_name,u.user_last_name FROM club_ticket_messages m JOIN users u ON u.user_serial=m.author_serial WHERE ticket_serial=? ORDER BY message_serial',[$id],$page,'messages');
}
public function createTicket(int $club,string $subject,string $message): int {
$subject=$this->text($subject,self::SUBJECT_LIMIT,'Subject');$message=$this->text($message,self::MESSAGE_LIMIT,'Message');
if(!(new AuthService())->allowAttempt('club-ticket',(string)($_SESSION[self::USER_SERIAL]??0),20,3600))throw new AppException('Too many tickets. Try again later.',429);
(new ClubResources())->consume($club,'ticket');
$db=$this->connect();return DatabaseTransaction::run($db,function(PDO $db)use($club,$subject,$message){
$selected=$this->club($club,$db);$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409);
$this->club($club,$db);$stmt=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$stmt->execute([$club,$_SESSION[self::USER_SERIAL],$subject]);$id=(int)$db->lastInsertId();
$stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'opened club ticket '.json_encode(['club'=>$club,'ticket'=>$id]));return $id;
});
}
public function reply(int $club,int $id,string $message,string $status=''): void {
$message=$this->text($message,self::MESSAGE_LIMIT,'Message');if(!in_array($status,['','open','closed'],true))throw new AppException('Invalid ticket status.');
if(!(new AuthService())->allowAttempt('ticket-reply',(string)($_SESSION[self::USER_SERIAL]??0),60,3600))throw new AppException('Too many replies. Try again later.',429);
$this->ticket($club,$id);(new ClubResources())->consume($club,'reply');
$db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id,$message,$status){
$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409);
$row=$this->ticket($club,$id,$db);if($status!=='')(new TenantAccess())->requireManager($club,$db);
if($row['ticket_status']==='closed'&&$status!=='open')throw new AppException('A club administrator must reopen the ticket before replying.',409);
$stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]);
$stmt=$db->prepare('UPDATE club_tickets SET ticket_status=?,updated_at=CURRENT_TIMESTAMP WHERE ticket_serial=?');$stmt->execute([$status?:$row['ticket_status'],$id]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'replied to club ticket '.json_encode(['club'=>$club,'ticket'=>$id,'status'=>$status?:$row['ticket_status']]));
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/** Retry only complete, rollback-safe database work; never put email delivery here. */
final class DatabaseTransaction {
public static function run(PDO $db,callable $work): mixed {
if($db->inTransaction())throw new LogicException('Retryable work must own its transaction.');
for($attempt=1;$attempt<=3;$attempt++){
$db->beginTransaction();
try{
$result=$work($db);$db->commit();return $result;
}catch(Throwable $error){
if($db->inTransaction())$db->rollBack();
$retryable=$error instanceof PDOException && (in_array((int)($error->errorInfo[1]??0),[1205,1213],true)||$error->getCode()==='40001');
if(!$retryable||$attempt===3)throw $error;
usleep(random_int(10000,30000)*$attempt);
}
}
throw new LogicException('Transaction attempts exhausted.');
}
}
+10 -8
View File
@@ -5,8 +5,8 @@ class LedgerService extends RosterService {
private function key(string $key): void {if(!preg_match('/\A[a-f0-9]{64}\z/',$key))throw new AppException('Invalid request key.');}
private function reason(string $reason): string {$reason=trim($reason);if(!$reason||strlen($reason)>500)throw new AppException('Enter a correction reason, up to 500 bytes.');return $reason;}
private function transaction(callable $work): void {
$this->requireAdministrator();$db=$this->connect();$db->beginTransaction();
try{$work($db);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
$this->requireAdministrator();$db=$this->connect();
DatabaseTransaction::run($db,$work);
}
private function lockedDue(PDO $db,int $club,int $due): array {
$this->club($club,$db,true);
@@ -25,11 +25,12 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'created dues '.json_encode(['due'=>(int)$db->lastInsertId(),'club'=>$club,'membership'=>$membership,'season'=>$season,'amount_cents'=>$cents]));
});
}
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference='',?int $membership=null): void {
$cents=self::cents($amount);self::date($paid);$this->key($key);$reference=trim($reference);
if($cents<=0||$paid>date('Y-m-d')||!in_array($method,['unspecified','cash','check','transfer','other'],true)||strlen($reference)>100)throw new AppException('Enter a positive payment, a valid date/method, and a reference up to 100 bytes.');
$this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference){
$this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference,$membership){
$row=$this->lockedDue($db,$club,$due);
if($membership!==null&&(int)$row['membership_serial']!==$membership)throw new AppException('Dues record does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_payments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['amount_cents']!==$cents||$existing['paid_on']!==$paid||$existing['payment_method']!==$method||$existing['payment_reference']!==$reference)throw new AppException('This request key was already used for different payment details.',409);return;}
if($cents>$row['effective']-$row['paid'])throw new AppException('Payment exceeds the outstanding balance.',409);
@@ -59,10 +60,11 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'reversed payment '.json_encode(['reversal'=>(int)$db->lastInsertId(),'club'=>$club,'payment'=>$payment,'amount_cents'=>(int)$row['amount_cents'],'reason'=>$reason]));
});
}
public function history(int $club): array {
public function history(int $club,?int $page=null,?int $adjustmentPage=null,?int $membership=null): array {
$this->club($club);$result=[];$db=$this->connect();
$scope=$this->administrator()?'':' AND m.user_serial=?';$args=$this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
$stmt=$db->prepare('SELECT p.*,m.user_serial,r.reversal_serial,r.reason AS reversal_reason,r.created_at AS reversed_at,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor,ra.user_name AS reversal_actor FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial LEFT JOIN club_payment_reversals r ON r.payment_serial=p.payment_serial LEFT JOIN users a ON a.user_serial=p.recorded_by LEFT JOIN users ra ON ra.user_serial=r.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY p.paid_on DESC,p.payment_serial DESC');$stmt->execute($args);$result['payments']=$stmt->fetchAll();
$stmt=$db->prepare('SELECT x.*,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor FROM club_due_adjustments x JOIN club_dues d ON d.due_serial=x.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN users a ON a.user_serial=x.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY x.adjustment_serial DESC');$stmt->execute($args);$result['adjustments']=$stmt->fetchAll();return $result;
$scope=$this->manages($club)?'':' AND m.user_serial=?';$args=$this->manages($club)?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
if($membership!==null){$this->member($club,$membership);$scope.=' AND m.membership_serial=?';$args[]=$membership;}
$result['payments']=BoundedList::rows($db,'SELECT p.*,m.user_serial,r.reversal_serial,r.reason AS reversal_reason,r.created_at AS reversed_at,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor,ra.user_name AS reversal_actor FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial LEFT JOIN club_payment_reversals r ON r.payment_serial=p.payment_serial LEFT JOIN users a ON a.user_serial=p.recorded_by LEFT JOIN users ra ON ra.user_serial=r.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY p.paid_on DESC,p.payment_serial DESC',$args,$page,'payments');
$result['adjustments']=BoundedList::rows($db,'SELECT x.*,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor FROM club_due_adjustments x JOIN club_dues d ON d.due_serial=x.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN users a ON a.user_serial=x.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY x.adjustment_serial DESC',$args,$adjustmentPage,'adjustments');return $result;
}
}
+25 -29
View File
@@ -120,19 +120,20 @@ class MaintenanceService extends Base {
}
private function lockAdministrators(PDO $db): array {
return $db->query("SELECT user_serial FROM users WHERE user_status='active' AND user_role IN ('Administrator','System Administrator') ORDER BY user_serial FOR UPDATE")->fetchAll(PDO::FETCH_COLUMN);
return $db->query("SELECT user_serial FROM users WHERE user_status='active' AND user_role='Super System Administrator' ORDER BY user_serial FOR UPDATE")->fetchAll(PDO::FETCH_COLUMN);
}
private function guardAdministrator(array $admins, int $id, string $role, string $status, bool $deleting = false): void {
if ($deleting && $id === (int) ($_SESSION[self::USER_SERIAL] ?? 0))
throw new AppException('You cannot delete your own account.', 409);
if (in_array($id, array_map('intval', $admins), true) && count($admins) <= 1 && ($deleting || $status !== 'active' || !in_array($role, ['Administrator', 'System Administrator'], true))) {
throw new AppException('The last active administrator cannot be removed, disabled or demoted.', 409);
if (in_array($id, array_map('intval', $admins), true) && count($admins) <= 1 && ($deleting || $status !== 'active' || $role!==TenantAccess::SUPER)) {
throw new AppException('The last active Super System Administrator cannot be removed, disabled or demoted.', 409);
}
}
public function userUsed(int $id, ?PDO $db = null): bool {
$db ??= $this->connect();
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?");
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0)
@@ -149,20 +150,25 @@ class MaintenanceService extends Base {
if ((int) $stmt->fetchColumn() > 0)
return true;
}
foreach(['club_invitations'=>'invited_by','club_tickets'=>'user_serial','club_ticket_messages'=>'author_serial'] as $table=>$column){
$stmt=$db->prepare("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?");$stmt->execute([$table]);
if($stmt->fetchColumn()){$stmt=$db->prepare('SELECT 1 FROM '.$table.' WHERE '.$column.'=? LIMIT 1');$stmt->execute([$id]);if($stmt->fetchColumn())return true;}
}
return false;
}
public function deleteUser(int $id): void {
(new TenantAccess())->requireSuper();
$db = $this->connect();
$db->beginTransaction();
try {
DatabaseTransaction::run($db,function(PDO $db)use($id){
$admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT user_role,user_status FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]);
$user = $stmt->fetch();
if (!$user)
throw new AppException('User not found.', 404);
if ($user['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
if ($user['user_role'] === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $user['user_role'], $user['user_status'], true);
if ($this->userUsed($id, $db))
@@ -172,35 +178,32 @@ class MaintenanceService extends Base {
$stmt = $db->prepare('DELETE FROM users WHERE user_serial=?');
$stmt->execute([$id]);
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'deleted unused user ' . $id);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
});
}
public function updateUser(int $id, array $input): void {
(new TenantAccess())->requireSuper();
$name = trim($input['user_name'] ?? '');
$email = trim($input['user_email'] ?? '');
$first = trim($input['user_first_name'] ?? '');
$last = trim($input['user_last_name'] ?? '');
$role = $input['user_role'] ?? '';
$status = self::boolean($input['user_active'] ?? false) ? 'active' : 'inactive';
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !in_array($role, ['User', 'Guest', 'Administrator', 'System Administrator'], true))
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !in_array($role, ['User', 'Guest', 'Administrator', TenantAccess::SUPER], true))
throw new AppException('Enter a valid username, first/last name, email and role.');
// Only an existing system administrator may assign that role.
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
if ($role === TenantAccess::SUPER && !(new TenantAccess())->isSuper())
throw new AppException('Only a system administrator can assign that role.', 403);
$db = $this->connect();
$db->beginTransaction();
try {
DatabaseTransaction::run($db,function(PDO $db)use($id,$name,$email,$first,$last,$role,$status){
$admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]);
$old = $stmt->fetch();
if (!$old)
throw new AppException('User not found.', 404);
if ($old['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
if ($old['user_role'] === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $role, $status);
$revoke = $old['user_role'] !== $role || $old['user_status'] !== $status || $old['user_email'] !== $email || $old['user_name'] !== $name;
@@ -218,24 +221,21 @@ class MaintenanceService extends Base {
}
$fields = ['user_name', 'user_email', 'user_first_name', 'user_last_name', 'user_role', 'user_status'];
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'updated user ' . json_encode(['user' => $id, 'before' => array_intersect_key($old, array_flip($fields)), 'after' => ['user_name' => $name, 'user_email' => $email, 'user_first_name' => $first, 'user_last_name' => $last, 'user_role' => $role, 'user_status' => $status]]));
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
});
}
public function setUserActive(int $id, bool $active): void {
(new TenantAccess())->requireSuper();
$db = $this->connect();
$db->beginTransaction();
try {
DatabaseTransaction::run($db,function(PDO $db)use($id,$active){
$admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT user_role FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]);
$role = $stmt->fetchColumn();
if (!$role)
throw new AppException('User not found.', 404);
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
if ($role === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $role, $active ? 'active' : 'inactive');
$stmt = $db->prepare('UPDATE users SET user_status=?,user_auth_version=user_auth_version+1 WHERE user_serial=?');
@@ -245,11 +245,7 @@ class MaintenanceService extends Base {
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
$stmt->execute([$id]);
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'changed user status ' . json_encode(['user' => $id, 'active' => $active]));
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
});
}
public function deleteStatus(int $id): void {
+45
View File
@@ -0,0 +1,45 @@
<?php
/** The same rules apply to create and edit, regardless of browser validation. */
final class MaintenanceValidation {
private static function text(array $input, string $field, string $label, int $limit, bool $required = false, bool $singleLine = false): string {
$value = $input[$field] ?? '';
if (!is_string($value) || !mb_check_encoding($value, 'UTF-8') || str_contains($value, "\0"))
throw new AppException($label . ' must contain valid text.');
$value = trim($value);
if ($required && ($value === '' || preg_match('/\A[\s\p{Z}]*\z/u', $value)))
throw new AppException($label . ' is required.');
if (mb_strlen($value, 'UTF-8') > $limit)
throw new AppException($label . ' must be ' . $limit . ' characters or fewer.');
if ($singleLine && preg_match('/[\x00-\x1f\x7f]/u', $value))
throw new AppException($label . ' must be a single line.');
return $value;
}
private static function timestamp(array $input, string $prefix, string $label): string {
$date = self::text($input, $prefix . '_date', $label . ' date', 10, true, true);
$time = self::text($input, $prefix . '_time', $label . ' time', 5, true, true);
$value = $date . ' ' . $time;
if (!preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}\z/', $value))
throw new AppException($label . ' must use a valid YYYY-MM-DD date and HH:MM time.');
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $value, new DateTimeZone(Base::TIMEZONE));
$errors = DateTimeImmutable::getLastErrors();
if (!$parsed || ($errors && ($errors['warning_count'] || $errors['error_count'])) || $parsed->format('Y-m-d H:i') !== $value || $date < '1000-01-01')
throw new AppException($label . ' date/time is invalid.');
return $value;
}
public static function alert(array $input): array {
$title = self::text($input, 'alert_title', 'Alert title', 100, true, true);
$starts = self::timestamp($input, 'alert_starts', 'Start');
$expires = self::timestamp($input, 'alert_expires', 'Expiration');
if ($expires < $starts)
throw new AppException('Expiration cannot be before the start.');
return ['title' => $title, 'starts' => $starts, 'expires' => $expires, 'message' => self::text($input, 'alert_message', 'Alert message', 1000)];
}
public static function popover(array $input): array {
return ['name' => self::text($input, 'popover_name', 'Popover name', 100, true, true), 'title' => self::text($input, 'popover_title', 'Popover title', 100, true, true), 'placeholder' => self::text($input, 'popover_placeholder', 'Popover placeholder', 100, false, true), 'text' => self::text($input, 'popover_text', 'Popover text', 1000)];
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/** A member summary always belongs to one authorized club membership. */
class MemberSummary extends Base {
private string $memberLabel='';
private static function h(mixed $value): string {return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
private static function money(int $cents): string {return '$'.number_format($cents/100,2);}
private function form(string $step,int $club,int $membership): string {
$url='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons')]);
return '<form method="post" action="'.self::h($url).'" class="row g-2 mb-3"><p class="text-secondary">Member: '.self::h($this->memberLabel).'</p><input type="hidden" name="action" value="Roster.memberSummary"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="step" value="'.$step.'"><input type="hidden" name="club_serial" value="'.$club.'"><input type="hidden" name="membership_serial" value="'.$membership.'">';
}
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
$id='member_'.$name.'_'.bin2hex(random_bytes(3));
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" name="'.$name.'" type="'.$type.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
}
public function show(): void {
$club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null);
$membership=MaintenanceService::id($_GET['membership_serial']??$_POST['membership_serial']??null);
$service=new RosterService();$member=$service->member($club,$membership);$selected=$service->club($club);
$this->memberLabel=trim($member['user_first_name'].' '.$member['user_last_name']).' ('.$member['user_email'].')';
$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$active=$selected['club_status']==='active';
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();(new TenantAccess())->requireManager($club);
if(!$active)throw new AppException('Reactivate the club before changing records.',409);
if((int)($_POST['membership_serial']??0)!==$membership||(int)($_POST['club_serial']??0)!==$club)throw new AppException('The selected membership changed. Reload its summary.',409);
switch($_POST['step']??''){
case 'member':$service->saveMember($club,$membership,'',$_POST['membership_status']??'',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
case 'charge':$service->charge($club,$membership,MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
case 'payment':(new LedgerService())->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'',$membership);break;
default:throw new AppException('Unknown member action.');
}
$_SESSION['roster_notice']='Changes saved.';
$redirect='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons')]);
if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return;}
header('Location: '.$redirect,true,303);return;
}
$dues=$service->dues($club,BoundedList::page('dues'),$membership);
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'),$membership);
// Totals include every season, independently of the visible history pages.
$stmt=$this->connect()->prepare('SELECT COALESCE(SUM(d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0)),0) AS dues_cents,COALESCE(SUM(COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0)),0) AS paid_cents FROM club_dues d WHERE d.club_serial=? AND d.membership_serial=?');
$stmt->execute([$club,$membership]);$totals=$stmt->fetch();
$back='main.php?'.http_build_query(['Xaction'=>'Roster.workspace','club_serial'=>$club,'members_page'=>BoundedList::page('members')]).'#roster';
$name=trim($member['user_first_name'].' '.$member['user_last_name']);
$html='<main class="container-fluid py-3 memberSummary"><header class="mb-4"><h1>'.self::h($name).'</h1><p>'.self::h($member['user_email']).'</p><p>Club: '.self::h($selected['club_name']).' · '.self::h(ucfirst($selected['club_status'])).'</p><p>Membership: '.self::h(ucfirst($member['membership_status'])).' · '.self::h(ucfirst($member['membership_role'])).'<br>Joined: '.self::h($member['joined_on']).'</p><a class="btn btn-outline-primary" href="'.self::h($back).'">Back to club roster</a></header>';
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
if($manage&&$active){
$html.='<nav class="d-flex flex-wrap gap-2 mb-3" aria-label="Member actions">';
if($super||$member['membership_role']!=='administrator'){
$html.=$this->form('member',$club,$membership).self::field('joined_on','Joined on','date',$member['joined_on']).'<label class="form-label">Membership status<select class="form-select" name="membership_status"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select></label>';
if($super)$html.='<label class="form-label">Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>';
else $html.='<input type="hidden" name="membership_role" value="member">';
$html.='<button class="btn btn-primary">Save membership</button></form>';
}
if($member['membership_status']==='active'){
$seasons=$service->seasons($club,BoundedList::page('seasons'));
if($seasons){$html.=$this->form('charge',$club,$membership).'<p>Add dues for '.self::h($name).'.</p><label class="form-label" for="member_dues_season">Season</label><select class="form-select" name="season_serial" id="member_dues_season">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';$html.='</select>'.self::field('amount','Dues (USD)').'<button class="btn btn-primary">Save dues</button>'.BoundedList::navigation('seasons','Roster.memberSummary').'</form>';}
else $html.='<p>Add a season from the club page before recording dues.</p>';
}
$html.='</nav>';
}
$html.='<div class="row g-3 mb-4">';
foreach(['Total dues'=>(int)$totals['dues_cents'],'Payments received'=>(int)$totals['paid_cents'],'Balance due'=>(int)$totals['dues_cents']-(int)$totals['paid_cents']] as $label=>$amount)
$html.='<div class="col-12 col-md-4"><section class="card h-100"><div class="card-body"><h2 class="h6">'.$label.'</h2><p class="h4 mb-0">'.self::money($amount).'</p></div></section></div>';
$html.='</div><p>Amounts are in USD. Totals include all seasons and exclude reversed payments.</p><h2 class="h4 mt-4">Season dues</h2><div class="table-responsive"><table class="table"><thead><tr><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){
$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];
$html.='<tr><td>'.self::h($due['season_name']).'</td><td>'.self::money((int)$due['amount_cents']).'</td><td>'.self::money((int)$due['paid_cents']).'</td><td>'.self::money($balance).'</td>';
if($manage&&$active){
$html.='<td>';
if($balance>0)$html.=$this->form('payment',$club,$membership).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-primary">Record payment</button></form>';
$html.='</td>';
}
$html.='</tr>';
}
if(!$dues)$html.='<tr><td colspan="'.($manage&&$active?5:4).'">No dues recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('dues','Roster.memberSummary').'<h2 class="h4 mt-4">Payment history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Status</th></tr></thead><tbody>';
foreach($history['payments'] as $payment){
$status=$payment['reversal_serial']?'Reversed: '.$payment['reversal_reason'].' ('.$payment['reversed_at'].' UTC)':'Received';
$html.='<tr><td>#'.(int)$payment['payment_serial'].' · Dues #'.(int)$payment['due_serial'].'</td><td>'.self::h($payment['paid_on']).'</td><td>'.self::money((int)$payment['amount_cents']).'</td><td>'.self::h($payment['payment_method'].' / '.$payment['payment_reference']).'</td><td>'.self::h($status).'</td></tr>';
}
if(!$history['payments'])$html.='<tr><td colspan="5">No payments recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('payments','Roster.memberSummary').'<h2 class="h4 mt-4">Dues correction history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Date (UTC)</th><th>Adjustment</th><th>Corrected total</th><th>Reason</th></tr></thead><tbody>';
foreach($history['adjustments'] as $adjustment)$html.='<tr><td>'.self::h($adjustment['created_at']).'</td><td>'.self::money((int)$adjustment['amount_cents']).'</td><td>'.self::money((int)$adjustment['corrected_amount_cents']).'</td><td>'.self::h($adjustment['reason']).'</td></tr>';
if(!$history['adjustments'])$html.='<tr><td colspan="4">No dues corrections recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('adjustments','Roster.memberSummary').'</main>';
$this->displayContent($manage&&$active?ClubFormModals::render($html):$html);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
final class MfaClock implements Psr\Clock\ClockInterface {
public function now(): DateTimeImmutable {
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/** Security notices contain event details, never authenticator or recovery secrets. */
class MfaSecurityNotifications extends Base {
public function send(array $account,string $event): bool {
$events=[
'enrolled'=>'An authenticator was added to your account.',
'replacement_started'=>'An authenticator replacement was requested. Your existing authenticator remains active until replacement is confirmed.',
'replacement_started_recovery'=>'A single-use recovery code was used to request authenticator replacement. Your existing authenticator remains active until replacement is confirmed.',
'replaced'=>'Your authenticator was replaced. Old recovery codes and other sign-in sessions have been revoked.',
'recovery_used'=>'A single-use recovery code was used to authenticate your account.'
];
if(!isset($events[$event]))throw new LogicException('Unknown MFA notification event.');
$h=static fn($text)=>htmlspecialchars((string)$text,ENT_QUOTES,'UTF-8');
$body='<p>'.$h($events[$event]).'</p><p>Account: '.$h($account['user_email']).'<br>Time (UTC): '.$h(gmdate('Y-m-d H:i:s')).'</p><p>If you did not authorize this activity, contact your site administrator immediately and secure your account.</p>';
$ok=false;
try{$ok=$this->deliver((string)$account['user_email'],self::APPLICATION_NAME.' - Account security notice',$body);}
catch(Throwable $error){/* Keep secrets and provider diagnostics out of this event log. */}
if(!$ok)error_log('MFA security notification failed user='.(int)$account['user_serial'].' event='.$event);
return $ok;
}
protected function deliver(string $to,string $subject,string $body): bool {return (new Email())->sendEmail($to,$subject,$body);}
public static function notice(): string {
if(empty($_SESSION['mfa_notification_warning']))return '';
unset($_SESSION['mfa_notification_warning']);
return '<p class="alert alert-warning" role="alert">Your security action succeeded, but its notification email could not be sent. Please contact support.</p>';
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
use OTPHP\TOTP;
/** Authentication factors belong to the account, never to a selected tenant. */
class MfaService extends Base {
public const RECENT_SECONDS = 300;
private function key(): string {
$encoded = getenv('HCR_MFA_KEY');
// Local development servers do not necessarily inherit shell variables.
// Keep their persistent secret outside the application and its releases.
if ($encoded === false) {
$settings = $this->getSettings();
$file = getenv('HCR_MFA_KEY_FILE');
if ($file === false)
$file = (string) ($settings->MfaKeyFile ?? '');
if ((string) $settings->Environment === 'Development' && $file !== '') {
$resolved = realpath($file);
$root = realpath(__DIR__ . '/..');
$normalize = static fn($path) => strtolower(str_replace('\\', '/', $path));
if ($resolved === false || !is_file($resolved) || !is_readable($resolved) || filesize($resolved) > 128 || str_starts_with($normalize($resolved), $normalize($root) . '/'))
throw new RuntimeException('Development MFA key must be a readable file outside the application directory.');
$encoded = trim(file_get_contents($resolved));
}
}
$key = base64_decode($encoded === false ? '' : $encoded, true);
if ($key === false || strlen($key) !== 32)
throw new RuntimeException('Configure HCR_MFA_KEY with a base64-encoded random 32-byte key.');
return $key;
}
public function sealSecret(string $secret, int $user): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($secret, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag, (string) $user);
if ($cipher === false)
throw new RuntimeException('Cannot protect authenticator secret.');
return base64_encode($iv . $tag . $cipher);
}
private function decrypt(string $cipher, int $user): string {
$raw = base64_decode($cipher, true);
if ($raw === false || strlen($raw) < 29)
throw new RuntimeException('Invalid authenticator secret.');
$secret = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), (string) $user);
if ($secret === false)
throw new RuntimeException('Cannot unlock authenticator secret.');return $secret;
}
public function enrolled(int $user): bool {
$stmt = $this->connect()->prepare('SELECT 1 FROM user_mfa WHERE user_serial=?');
$stmt->execute([$user]);
return (bool) $stmt->fetchColumn();
}
public function required(int $user): bool {
return (new TenantAccess())->managesAny() || $this->enrolled($user);
}
public function verified(int $user): bool {
return ($_SESSION['mfa_user'] ?? 0) === $user;
}
private function limited(int $user): void {
$auth = new AuthService();
if (!$auth->allowAttempt('mfa-account', (string) $user, 10, 300) || !$auth->allowAttempt('mfa-ip', $_SERVER['REMOTE_ADDR'] ?? '', 40, 300))
throw new AppException('Too many authentication attempts. Try again later.', 429);
}
private static function counter(string $secret, string $code, int $last): ?int {
if (!preg_match('/\A[0-9]{6}\z/', $code))
return null;
$otp = TOTP::createFromSecret($secret, new MfaClock());
$now = intdiv(time(), 30);
foreach ([$now, $now - 1, $now + 1] as $counter)
if ($counter > $last && hash_equals($otp->at($counter * 30), $code))
return $counter;
return null;
}
/** All factor mutations use the account-then-factor lock order. */
private function account(PDO $db,int $user,string $password): ?array {
$stmt=$db->prepare('SELECT user_serial,user_email,user_password_hash,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user]);$account=$stmt->fetch();
if(!$account||$account['user_status']!=='active'||($_SESSION[self::USER_SERIAL]??0)!==$user
||($_SESSION['auth_version']??-1)!==(int)$account['user_auth_version']
||!password_verify($password,$account['user_password_hash']))return null;
return $account;
}
private function factor(PDO $db,int $user): ?array {
$stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);return $stmt->fetch()?:null;
}
private function consume(PDO $db,int $user,array $factor,string $code): ?string {
$code=trim($code);$counter=self::counter($this->decrypt($factor['secret_cipher'],$user),$code,(int)$factor['last_counter']);
if($counter!==null){
$stmt=$db->prepare('UPDATE user_mfa SET last_counter=? WHERE user_serial=?');$stmt->execute([$counter,$user]);return 'totp';
}
$code=strtolower($code);if(!preg_match('/\A[a-f0-9]{32}\z/',$code))return null;
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=? AND code_hash=?');$stmt->execute([$user,hash('sha256',$code)]);
return $stmt->rowCount()===1?'recovery':null;
}
protected function notify(array $account,string $event): void {
if(!(new MfaSecurityNotifications())->send($account,$event))$_SESSION['mfa_notification_warning']=true;
}
public function verify(int $user,string $code,string $password): bool {
$this->limited($user);
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
$account=$this->account($db,$user,$password);if(!$account)return null;
$factor=$this->factor($db,$user);if(!$factor)return null;
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
Audit::required($db,$user,$kind==='recovery'?'used MFA recovery code':'verified authenticator');
return ['account'=>$account,'kind'=>$kind];
});
if(!$result)return false;
if($result['kind']==='recovery')$this->notify($result['account'],'recovery_used');
return true;
}
private function recoveryCodes(PDO $db,int $user): array {
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]);
$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$codes=[];
for($i=0;$i<10;$i++){$code=bin2hex(random_bytes(16));$codes[]=$code;$stmt->execute([$user,hash('sha256',$code)]);}
return $codes;
}
public function enroll(int $user,string $secret,string $code,string $password): array {
$this->limited($user);$pending=$_SESSION['mfa_pending']??[];
if(($pending['user']??0)!==$user||($pending['expires']??0)<time()||!hash_equals($pending['secret']??'',$secret))throw new AppException('Authenticator setup expired. Reload the page.',409);
$counter=self::counter($secret,trim($code),-1);if($counter===null)throw new AppException('Enter the current authenticator code.');
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$secret,$counter,$password){
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
if($this->factor($db,$user))throw new AppException('Authenticator is already enrolled.',409);
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher,last_counter) VALUES(?,?,?)');$stmt->execute([$user,$this->sealSecret($secret,$user),$counter]);
$codes=$this->recoveryCodes($db,$user);
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
Audit::required($db,$user,'enrolled authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
});
unset($_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);$_SESSION['auth_version']=$result['version'];
$this->notify($result['account'],'enrolled');return $result['codes'];
}
/** Prove the old factor, but keep it and the remaining recovery codes active. */
public function beginReplacement(int $user,string $code,string $password): bool {
$this->limited($user);
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
$account=$this->account($db,$user,$password);if(!$account)return null;
$factor=$this->factor($db,$user);if(!$factor)return null;
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
if($kind==='recovery')Audit::required($db,$user,'used MFA recovery code');
Audit::required($db,$user,'requested authenticator replacement');
return ['account'=>$account,'kind'=>$kind,'factor'=>hash('sha256',$factor['secret_cipher'])];
});
if(!$result)return false;
$_SESSION['mfa_replacement']=['user'=>$user,'version'=>(int)$result['account']['user_auth_version'],'factor'=>$result['factor'],
'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
$this->mark($user);
$this->notify($result['account'],$result['kind']==='recovery'?'replacement_started_recovery':'replacement_started');return true;
}
private function replacement(int $user): ?array {
$pending=$_SESSION['mfa_replacement']??null;
if(!$pending)return null;
if(($pending['user']??0)!==$user||($pending['version']??-1)!==($_SESSION['auth_version']??-2)||($pending['expires']??0)<time()){
unset($_SESSION['mfa_replacement']);return null;
}
return $pending;
}
public function completeReplacement(int $user,string $code,string $password): array {
$this->limited($user);$pending=$this->replacement($user);
if(!$pending)throw new AppException('Replacement setup expired. Your existing authenticator is still active.',409);
$counter=self::counter($pending['secret'],trim($code),-1);if($counter===null)throw new AppException('Enter the code from your new authenticator.');
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$password,$pending,$counter){
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
$factor=$this->factor($db,$user);
if(!$factor||!hash_equals($pending['factor'],hash('sha256',$factor['secret_cipher'])))throw new AppException('Authenticator changed. Start replacement again.',409);
$stmt=$db->prepare('UPDATE user_mfa SET secret_cipher=?,last_counter=? WHERE user_serial=?');$stmt->execute([$this->sealSecret($pending['secret'],$user),$counter,$user]);
$codes=$this->recoveryCodes($db,$user);
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
Audit::required($db,$user,'replaced authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
});
unset($_SESSION['mfa_replacement'],$_SESSION['mfa_pending']);$_SESSION['auth_version']=$result['version'];$this->mark($user);
$this->notify($result['account'],'replaced');return $result['codes'];
}
private function mark(int $user): void {
session_regenerate_id(true);$_SESSION['mfa_user']=$user;$_SESSION['recent_auth']=time();$_SESSION['csrf_token']=bin2hex(random_bytes(32));
}
public function gate(array $user,string $action): bool {
$id=(int)$user['user_serial'];
if($action!=='security'&&(!$this->required($id)||$this->verified($id)))return false;
if(!empty($_SESSION[self::AJAX])&&$action!=='security')throw new AppException('Complete administrator authentication from the home page.',403);
$this->page($user);return true;
}
public function page(array $user): void {
$id=(int)$user['user_serial'];$error='';$codes=[];$enrolled=$this->enrolled($id);
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();
try{
$action=$_POST['factor_action']??'';
if($action==='cancelReplacement'){
unset($_SESSION['mfa_replacement']);header('Location: main.php?Xaction=security',true,303);return;
}elseif($action==='confirmReplacement'){
$codes=$this->completeReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??'');
}elseif(!$enrolled){
$codes=$this->enroll($id,$_SESSION['mfa_pending']['secret']??'',$_POST['mfa_code']??'',$_POST['current_password']??'');$enrolled=true;$this->mark($id);
}elseif($action==='replace'){
if(!$this->beginReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
header('Location: main.php?Xaction=security',true,303);return;
}else{
if(!$this->verify($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
$this->mark($id);header('Location: main.php',true,303);return;
}
}catch(AppException $errorObject){http_response_code($errorObject->status);$error=$errorObject->getMessage();}
}
$pending=$this->replacement($id);$h=static fn($value)=>htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');
$html='<main class="container py-3"><h1>Account security</h1><p role="alert">'.$h($error).'</p>'.MfaSecurityNotifications::notice();
if($codes){
$html.='<h2>Save your recovery codes</h2><p>These single-use codes are shown only now. Store them securely away from your authenticator. You will need one if your device is lost.</p><pre>'.implode("\n",$codes).'</pre><a href="main.php">I have saved my recovery codes</a>';
}else{
if(!$enrolled||$pending){
if(!$pending){
$this->key();$initial=$_SESSION['mfa_pending']??[];
if(($initial['user']??0)!==$id||($initial['expires']??0)<time())$_SESSION['mfa_pending']=['user'=>$id,'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
}
$secret=$pending['secret']??$_SESSION['mfa_pending']['secret'];$qr=MfaSetupQr::dataUri($secret,$user['user_email']);
if($pending)$html.='<h2>Confirm your new authenticator</h2><p>Your existing authenticator remains active until you confirm this replacement. This setup expires after ten minutes.</p>';
$html.='<p>Scan this QR code with your authenticator app, then enter its six-digit code below.</p><img class="mfa-setup-qr" src="'.$qr.'" alt="Scan to set up your authenticator" width="320" height="320" style="max-width:100%;height:auto"><p>If you cannot scan it, use this setup key:</p><pre>'.$h($secret).'</pre><p>Account: '.$h($user['user_email']).'. Six digits, 30-second period, SHA-1.</p>';
}else $html.='<p>Enter your password and an authenticator code or a single-use recovery code.</p>';
$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'">';
if($pending)$html.='<input type="hidden" name="factor_action" value="confirmReplacement">';
$html.='<label>Current password<input class="form-control" type="password" name="current_password" autocomplete="current-password" required></label><label>Authentication code<input class="form-control" name="mfa_code" autocomplete="one-time-code" maxlength="32" required></label><button class="btn btn-primary">'.($pending?'Confirm replacement':'Verify').'</button>';
if($enrolled&&!$pending)$html.='<button class="btn btn-outline-warning" name="factor_action" value="replace">Replace lost authenticator</button><p>Replacement requires your password and a current authenticator or unused recovery code. Your old factor remains active until the new one is confirmed.</p>';
$html.='</form>';
if($pending)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'"><button class="btn btn-outline-secondary" name="factor_action" value="cancelReplacement">Cancel replacement</button></form>';
}
$this->displayContent($html.'</main>');
}
public function requireRecent(): void {
if(($_SESSION['recent_auth']??0)<time()-self::RECENT_SECONDS)throw new AppException('Open Account security from the home page, confirm your password and authenticator code, then submit the change again.',403);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/** Render the pending authenticator secret without external services or files. */
final class MfaSetupQr {
public static function dataUri(string $secret,string $email): string {
$otp=OTPHP\TOTP::createFromSecret($secret,new MfaClock())
->withIssuer(Base::APPLICATION_NAME)->withLabel($email);
$barcode=new TCPDF2DBarcode($otp->getProvisioningUri(),'QRCODE,M');
$matrix=$barcode->getBarcodeArray();
$size=(int)$matrix['num_cols'];$height=(int)$matrix['num_rows'];
if($size<1||$height!==$size)throw new RuntimeException('Cannot generate authenticator QR code.');
// A four-module white quiet zone keeps the image scannable on dark pages.
$edge=$size+8;$path='';
for($y=0;$y<$size;$y++)for($x=0;$x<$size;$x++){
if($matrix['bcode'][$y][$x])$path.='M'.($x+4).' '.($y+4).'h1v1h-1z';
}
$svg='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '.$edge.' '.$edge.'" width="'.$edge.'" height="'.$edge.'" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="'.$path.'" fill="black"/></svg>';
return 'data:image/svg+xml;base64,'.base64_encode($svg);
}
}
+10 -12
View File
@@ -51,10 +51,8 @@ class Popovers extends Base {
case "add":
$popover_name = filter_input(INPUT_POST, "popover_name") ?: "";
$popover_title = filter_input(INPUT_POST, "popover_title") ?: "";
$popover_placeholder = filter_input(INPUT_POST, "popover_placeholder") ?: "";
$popover_text = filter_input(INPUT_POST, "popover_text") ?: "";
$data=MaintenanceValidation::popover($_POST);
$popover_name=$data['name'];$popover_title=$data['title'];$popover_placeholder=$data['placeholder'];$popover_text=$data['text'];
$SQL = "insert into popovers
@@ -97,7 +95,7 @@ class Popovers extends Base {
public function deletePopover() {
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$popover = $this->getPopover($popover_serial);
@@ -123,7 +121,7 @@ class Popovers extends Base {
case "prompt":
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$popover = $this->getPopover($popover_serial);
@@ -141,11 +139,9 @@ class Popovers extends Base {
case "update":
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$popover_name = filter_input(INPUT_POST, "popover_name") ?: "";
$popover_title = filter_input(INPUT_POST, "popover_title") ?: "";
$popover_placeholder = filter_input(INPUT_POST, "popover_placeholder") ?: "";
$popover_text = filter_input(INPUT_POST, "popover_text") ?: "";
$popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$data=MaintenanceValidation::popover($_POST);
$popover_name=$data['name'];$popover_title=$data['title'];$popover_placeholder=$data['placeholder'];$popover_text=$data['text'];
$popover = $this->getPopover($popover_serial);
@@ -187,6 +183,7 @@ class Popovers extends Base {
// =======================
public function getPopover($popover_serial = 0) {
$popover_serial=MaintenanceService::id($popover_serial);
$SQL = "select view_popovers.*
from view_popovers
@@ -299,7 +296,8 @@ class Popovers extends Base {
public function popoverExists() {
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$popover_serial = filter_var($_POST['popover_serial']??0,FILTER_VALIDATE_INT,['options'=>['min_range'=>0]]);
if($popover_serial===false)throw new AppException('Invalid popover ID.');
$popover_name = filter_input(INPUT_POST, "popover_name") ?: "";
$popover_name = strtolower($popover_name);
+108 -37
View File
@@ -9,33 +9,48 @@ class Roster extends Base {
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" type="'.$type.'" name="'.$name.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
}
public function help(): void {
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Site administrators create clubs, assign existing accounts, add seasons, and record dues and received payments. Contact your club administrator for membership or account assistance.</p></main>');
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Your Super System Administrator creates clubs and assigns club administrators. Club administrators invite members, add seasons, record dues and payments, and answer tickets only in their clubs. Members can view club information and their own dues, and submit tickets. Accept invitations through Invitations after verifying your email.</p></main>');
}
public function home(): void {
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p><a href="main.php?Xaction=security">Account security and authenticator recovery</a></p><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
if(!$service->ready()){
$html.=$service->administrator()?'<p>Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.</p>':'<p>Your club workspace is being prepared. Please contact the site administrator.</p>';
}else{
$clubs=$service->clubs();
$clubs=$service->clubs(BoundedList::page('clubs'));
if((BoundedList::$pages['clubs']['count']??0)===1 && $clubs){
header('Location: main.php?Xaction=Roster.workspace&club_serial='.(int)$clubs[0]['club_serial'],true,303);
return;
}
if(!$clubs)$html.='<p>You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.</p>';
foreach($clubs as $club)$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$club['club_serial'].'">Open club</a></div></section>';
$html.='<div class="row">';
foreach($clubs as $club)$html.='<div class="col-12 col-md-6 col-xl-4 mb-3"><section class="card h-100"><div class="card-body d-flex flex-column"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary mt-auto align-self-start" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$club['club_serial'].'">Open club</a></div></section></div>';
$html.='</div>'.BoundedList::navigation('clubs','displayHome');
$html.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
}
$this->displayContent($html.'</div>');
$this->displayContent($html.MfaSecurityNotifications::notice().'</div>');
}
private function financialHistory(int $club,bool $active,bool $admin): string {
$history=(new LedgerService())->history($club);$html='<h3 class="h4 mt-4">Payment history</h3>';
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'));$html='<h2 class="h4 mt-4">Payment history</h2>';
if($admin)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.ledger"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="club_serial" value="'.$club.'"><button class="btn btn-outline-primary">Export financial history (CSV)</button></form>';
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
$html.='<div class="table-responsive position-relative"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
foreach($history['payments'] as $p){
$html.='<tr><td>#'.(int)$p['payment_serial'].'</td><td>'.self::h($p['member_name']).'</td><td>'.self::h($p['paid_on']).'</td><td>$'.number_format($p['amount_cents']/100,2).'</td><td>'.self::h($p['payment_method'].' '.$p['payment_reference']).'</td><td>'.self::h($p['actor']??'Historical record').'</td><td>';
if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']);
elseif($admin&&$active)$html.='<details><summary>Reverse incorrect payment</summary><p>This preserves the original entry and restores the amount owed. It does not send a refund.</p>'.$this->form('reverse',$club).'<input type="hidden" name="payment_serial" value="'.(int)$p['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<button class="btn btn-outline-danger">Confirm reversal</button></form></details>';
else $html.='Received';$html.='</td></tr>';
}
$html.='</tbody></table></div><h3 class="h4">Dues correction history</h3><ul>';
$html.='</tbody></table></div>'.BoundedList::navigation('payments').'<h2 class="h4 mt-4">Dues correction history</h2><ul>';
foreach($history['adjustments'] as $a)$html.='<li>'.self::h($a['member_name']).': $'.number_format($a['amount_cents']/100,2).' adjustment; corrected total $'.number_format($a['corrected_amount_cents']/100,2).' — '.self::h($a['reason'].' ('.$a['actor'].', '.$a['created_at'].')').'</li>';
return $html.'</ul>';
return $html.'</ul>'.BoundedList::navigation('adjustments');
}
public function createClub(): void {
(new TenantAccess())->requireSuper();$service=new RosterService();
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();$club=$service->saveClub(0,$_POST['club_name']??'');
$_SESSION['roster_notice']='Club created.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);return;
}
$form=str_replace('value="Roster.workspace"','value="Roster.createClub"',$this->form('club'));
$this->displayContent('<main class="container-fluid py-3"><h1>Create club</h1><p>Create a separate club, then assign its administrator from the club roster.</p>'.$form.self::field('club_name','Club name').'<div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form><a class="btn btn-outline-primary" href="main.php">Back to clubs</a></main>');
}
public function workspace(): void {
$service=new RosterService();if(!$service->ready()){$this->home();return;}
@@ -44,7 +59,9 @@ class Roster extends Base {
$service->requireAdministrator();$this->requireCsrf();
switch($_POST['step']){
case 'club':$club=$service->saveClub($club,$_POST['club_name']??'',$_POST['club_status']??'active');break;
case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'');break;
case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
case 'invite':$result=(new ClubInvitations())->invite($club,$_POST['invite_email']??'',$_POST['membership_role']??'member');$_SESSION['club_notice']=$result['message'].' '.$result['warning'];break;
case 'revokeInvitation':(new ClubInvitations())->revoke($club,MaintenanceService::id($_POST['invitation_serial']??null));$_SESSION['club_notice']='Invitation revoked.';break;
case 'season':$service->addSeason($club,$_POST['season_name']??'',$_POST['starts_on']??'',$_POST['ends_on']??'');break;
case 'charge':$service->charge($club,MaintenanceService::id($_POST['membership_serial']??null),MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
case 'payment':$service->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'');break;
@@ -52,45 +69,99 @@ class Roster extends Base {
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'');break;
default:throw new AppException('Unknown club action.');
}
$_SESSION['roster_notice']='Changes saved.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);exit;
$_SESSION['roster_notice']='Changes saved.';
$redirect='main.php?Xaction=Roster.workspace&club_serial='.$club;
if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){
header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return;
}
header('Location: '.$redirect,true,303);exit;
}
$html='<div class="container-fluid py-3"><h1>Clubs and memberships</h1>';
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
$clubs=$service->clubs();$html.='<nav aria-label="Clubs" class="mb-3">';
foreach($clubs as $row)$html.='<a class="btn btn-outline-primary me-2 mb-2" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$row['club_serial'].'">'.self::h($row['club_name']).'</a>';
$html.='</nav>';
if($service->administrator())$html.='<section><h2 class="h4">Create club</h2>'.$this->form('club').self::field('club_name','Club name').' <div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form></section>';
$clubs=$service->clubs(BoundedList::page('clubs'));
if(!$club && $clubs)$club=(int)$clubs[0]['club_serial'];
if(!$club){$this->displayContent($html.'<p>No clubs are available.</p></div>');return;}
$selected=$service->club($club);$members=$service->members($club);$seasons=$service->seasons($club);$dues=$service->dues($club);
$html.='<hr><h2>'.self::h($selected['club_name']).'</h2>';
if($service->administrator()){
$html.='<details class="mb-3"><summary>Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
if(!$club){$this->displayContent('<main class="container-fluid py-3"><h1>Clubs and memberships</h1><p>No clubs are available. Accept an invitation to join your club.</p><a class="btn btn-primary" href="main.php?Xaction=Roster.invitations">View invitations</a></main>');return;}
$selected=$service->club($club);$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$members=$service->members($club,BoundedList::page('members'));$seasons=$service->seasons($club,BoundedList::page('seasons'));$dues=$service->dues($club,BoundedList::page('dues'));
$html='<main class="container-fluid py-3 clubSummary"><header class="mb-4"><h1>'.self::h($selected['club_name']).'</h1><p>Club status: '.self::h(ucfirst($selected['club_status'])).' · '.($super?'Super System Administrator':($manage?'Club administrator':'Member')).'</p><p>'.($manage?'Manage your club roster, invitations, seasons and dues.':'View your membership, season dues and payment history.').'</p><nav class="d-flex flex-wrap gap-2" aria-label="Club actions"><a class="btn btn-primary" href="main.php?Xaction=Roster.tickets&amp;club_serial='.$club.'">'.($manage?'Club tickets':'My tickets').'</a><a class="btn btn-outline-primary" href="#roster">'.($manage?'View roster':'My membership').'</a><a class="btn btn-outline-primary" href="#dues">'.($manage?'Season dues':'My dues').'</a>';
if($manage && $selected['club_status']==='active')$html.='<a class="btn btn-outline-primary" href="#club-invitations">Invite members</a>';
if((BoundedList::$pages['clubs']['count']??0)>1){
$html.='<a class="btn btn-outline-primary" href="main.php?Xaction=displayHome">Switch club</a>';
}
$html.='<h3 class="h4">Roster</h3><div class="table-responsive"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($service->administrator()?'<th>Update membership</th>':'').'</tr></thead><tbody>';
$html.='</nav></header>'.MfaSecurityNotifications::notice();
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
if(isset($_SESSION['club_notice'])){$html.='<p role="status">'.self::h($_SESSION['club_notice']).'</p>';unset($_SESSION['club_notice']);}
if($manage){
$html.='<details class="mb-3"><summary class="btn btn-outline-primary">Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
}
$html.='<h2 class="h4 mt-4" id="roster">'.($manage?'Roster':'My membership').'</h2><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($manage?'<th>Update membership</th>':'').'</tr></thead><tbody>';
foreach($members as $member){
$html.='<tr><td>'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status']).'</td><td>'.self::h($member['joined_on']).'</td>';
if($service->administrator())$html.='<td>'.$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'"><label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form></td>';
$html.='<tr><td><a href="main.php?Xaction=Roster.memberSummary&amp;club_serial='.$club.'&amp;membership_serial='.(int)$member['membership_serial'].'&amp;members_page='.(int)BoundedList::$pages['members']['page'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</a></td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status'].' / '.$member['membership_role']).'</td><td>'.self::h($member['joined_on']).'</td>';
if($manage)$html.='<td>'.(($super||$member['membership_role']!=='administrator')?$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'">'.($super?'<label>Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>':'<input type="hidden" name="membership_role" value="member">').'<label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form>':'Club administrator (managed by Super System Administrator)').'</td>';
$html.='</tr>';
}
if(!$members)$html.='<tr><td colspan="5">No members assigned.</td></tr>';
$html.='</tbody></table></div>';
if($service->administrator() && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Create or invite accounts through Users before assigning them to a club.</p>';
$html.='<h3 class="h4">Seasons</h3><ul>';
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
$html.='</ul>';
if($service->administrator() && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
$html.='<h3 class="h4" id="dues">'.($service->administrator()?'Season dues':'My season dues').'</h3><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($service->administrator()?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
if($service->administrator()){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('members');
if($super && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Global account management is available through Users. To appoint an administrator, assign the account, then change its club role above.</p>';
if($manage && $selected['club_status']==='active'){
$html.='<h2 class="h4 mt-4" id="club-invitations">Invitations</h2>'.$this->form('invite',$club).self::field('invite_email','Invitation email','email','',100);
if($super)$html.='<label>Club role<select class="form-select" name="membership_role"><option value="member">Member</option><option value="administrator">Administrator</option></select></label>';
$html.='<button class="btn btn-primary">Send club invitation</button></form><p>The recipient signs up or signs in using this email, verifies it, and accepts under Invitations. This does not change their login or any other club membership.</p><ul>';
foreach((new ClubInvitations())->forClub($club,BoundedList::page('invitations')) as $invite){$html.='<li>'.self::h($invite['invite_email'].' — '.$invite['membership_role'].' — '.$invite['invitation_status'].' (expires '.$invite['expires_at'].' UTC)');if($invite['invitation_status']==='pending'&&($super||$invite['membership_role']==='member'))$html.=$this->form('revokeInvitation',$club).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-outline-danger">Revoke invitation</button></form>';$html.='</li>';}$html.='</ul>'.BoundedList::navigation('invitations');
}
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>';
if($service->administrator() && $selected['club_status']==='active' && $members && $seasons){
$html.='<h2 class="h4 mt-4">Seasons</h2><ul>';
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
$html.='</ul>'.BoundedList::navigation('seasons');
if($manage && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
$html.='<h2 class="h4 mt-4" id="dues">'.($manage?'Season dues':'My season dues').'</h2><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
if($manage){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
}
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>'.BoundedList::navigation('dues');
if($manage && $selected['club_status']==='active' && $members && $seasons){
$html.='<h4 class="h5">Record season dues</h4>'.$this->form('charge',$club).'<div class="col-md"><label class="form-label">Member<select class="form-select" name="membership_serial">';foreach($members as $member)if($member['membership_status']==='active')$html.='<option value="'.(int)$member['membership_serial'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</option>';
$html.='</select></label></div><div class="col-md"><label class="form-label">Season<select class="form-select" name="season_serial">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';
$html.='</select></label></div>'.self::field('amount','Dues (USD)','text').'<div class="col-auto align-self-end"><button class="btn btn-primary">Save dues</button></div></form>';
}
$html.=$this->financialHistory($club,$selected['club_status']==='active',$service->administrator());
$html.=$this->financialHistory($club,$selected['club_status']==='active',$manage);
$this->displayContent(ClubFormModals::render($html.'</main>'));
}
public function invitations(): void {
if(!(new RosterService())->ready()){$this->home();return;}
$service=new ClubInvitations();
if(($_POST['step']??'')==='accept'){$service->accept(MaintenanceService::id($_POST['invitation_serial']??null));header('Location: main.php?Xaction=Roster.workspace',true,303);exit;}
$html='<div class="container py-3"><h1>Club invitations</h1><p>Sign in using the invited email and verify it before accepting. Club invitations never change your account password or other club memberships.</p>';
$pending=$service->pending(BoundedList::page('pending'));
foreach($pending as $invite)$html.='<section><h2 class="h4">'.self::h($invite['club_name']).'</h2><p>Club role: '.self::h($invite['membership_role']).'; expires '.self::h($invite['expires_at']).' UTC.</p>'.str_replace('Roster.workspace','Roster.invitations',$this->form('accept')).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-primary">Accept invitation</button></form></section>';
if(!$pending)$html.='<p>No pending invitations for your current email.</p>';
$html.=BoundedList::navigation('pending');
$this->displayContent($html.'</div>');
}
public function tickets(): void {
$service=new ClubTickets();if(!$service->ready()){$this->home();return;}
$clubs=$service->clubs(BoundedList::page('clubs'));$raw=$_POST['club_serial']??$_GET['club_serial']??'';$club=$raw===''?($clubs?(int)$clubs[0]['club_serial']:0):MaintenanceService::id($raw);
$html='<div class="container py-3"><h1>Club tickets</h1><nav>';
foreach($clubs as $option)$html.='<a class="btn btn-outline-primary me-2" href="main.php?Xaction=Roster.tickets&amp;club_serial='.(int)$option['club_serial'].'">'.self::h($option['club_name']).'</a>';$html.='</nav>'.BoundedList::navigation('clubs');
if(!$club){$this->displayContent($html.'<p>Accept a club invitation to submit a ticket.</p></div>');return;}
$selected=$service->club($club);$manage=$service->manages($club);$raw=$_POST['ticket_serial']??$_GET['ticket_serial']??'';$ticket=$raw===''?0:MaintenanceService::id($raw);
$page=filter_var($_POST['ticket_page']??$_GET['ticket_page']??'1',FILTER_VALIDATE_INT,['options'=>['min_range'=>1]]);
if($page===false)throw new AppException('Choose a valid ticket page.');
$search=$service->normalizeSearch($_POST['ticket_search']??$_GET['ticket_search']??'');
if(($_POST['step']??'')==='openTicket')$ticket=$service->createTicket($club,$_POST['subject']??'',$_POST['message']??'');
elseif(($_POST['step']??'')==='reply')$service->reply($club,$ticket,$_POST['message']??'',$_POST['ticket_status']??'');
if(($_POST['step']??'')!==''){if(!in_array($_POST['step'],['openTicket','reply'],true))throw new AppException('Unknown ticket action.');header('Location: main.php?Xaction=Roster.tickets&club_serial='.$club.'&ticket_serial='.$ticket.($_POST['step']==='reply'?'&ticket_page='.$page.'&ticket_search='.rawurlencode($search):''),true,303);exit;}
$listing=$service->page($club,$page,$search);$page=$listing['page'];
$ticketUrl=static fn(int $target)=>'main.php?'.http_build_query(['Xaction'=>'Roster.tickets','club_serial'=>$club,'ticket_page'=>$target,'ticket_search'=>$search],'','&',PHP_QUERY_RFC3986);
$html.='<h2>'.self::h($selected['club_name']).'</h2><p>'.($manage?'Tickets from this club.':'Only your own tickets are shown.').'</p>';
$html.='<form method="get" action="main.php" class="row g-2 mb-3"><input type="hidden" name="Xaction" value="Roster.tickets"><input type="hidden" name="club_serial" value="'.$club.'"><label for="ticket_search">Search ticket subjects</label><input class="form-control" id="ticket_search" name="ticket_search" value="'.self::h($search).'" maxlength="200" data-character-limit="100"><button class="btn btn-outline-primary">Search tickets</button><a href="'.self::h('main.php?Xaction=Roster.tickets&club_serial='.$club).'">Clear search</a></form>';
$html.='<p role="status" class="ticket-page-status">Tickets '.$listing['start'].'-'.$listing['end'].' of '.$listing['count'].'. Page '.$page.' of '.$listing['pages'].'.</p><ul class="ticket-list">';
foreach($listing['rows'] as $row)$html.='<li><a href="'.self::h($ticketUrl($page).'&ticket_serial='.(int)$row['ticket_serial']).'">#'.(int)$row['ticket_serial'].' '.self::h($row['subject']).'</a> — '.self::h($row['ticket_status']).'</li>';
if(!$listing['rows'])$html.='<li>No matching tickets.</li>';$html.='</ul><nav aria-label="Ticket pages" class="d-flex gap-3 mb-3">';
if($page>1)$html.='<a href="'.self::h($ticketUrl(1)).'">First page</a><a href="'.self::h($ticketUrl($page-1)).'">Previous page</a>';
if($page<$listing['pages'])$html.='<a href="'.self::h($ticketUrl($page+1)).'">Next page</a><a href="'.self::h($ticketUrl($listing['pages'])).'">Last page</a>';$html.='</nav>';
if($ticket){$row=$service->ticket($club,$ticket);$html.='<h3>'.self::h($row['subject']).'</h3><p>Status: '.self::h($row['ticket_status']).'</p>';foreach($service->messages($club,$ticket,BoundedList::page('messages')) as $message)$html.='<article class="card p-3 mb-2"><p>'.self::h($message['user_first_name'].' '.$message['user_last_name'].' — '.$message['created_at']).'</p><p class="text-break">'.nl2br(self::h($message['message_text'])).'</p></article>';
$html.=BoundedList::navigation('messages');
if($selected['club_status']==='active'&&($manage||$row['ticket_status']==='open')){$html.=str_replace('Roster.workspace','Roster.tickets',$this->form('reply',$club)).'<input type="hidden" name="ticket_page" value="'.$page.'"><input type="hidden" name="ticket_search" value="'.self::h($search).'"><input type="hidden" name="ticket_serial" value="'.$ticket.'"><label>Reply<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label>';if($manage)$html.='<label for="ticket_status">Status</label><select id="ticket_status" class="form-select" name="ticket_status"><option value="open">Open</option><option value="closed">Closed</option></select>';$html.='<button class="btn btn-primary">Send reply</button></form>';}
}
if($selected['club_status']==='active')$html.='<h3 class="h4">Submit a ticket</h3>'.str_replace('Roster.workspace','Roster.tickets',$this->form('openTicket',$club)).str_replace('name="subject"','name="subject" data-character-limit="'.ClubTickets::SUBJECT_LIMIT.'"',self::field('subject','Subject','text','',ClubTickets::SUBJECT_LIMIT*2)).'<label>Message<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label><button class="btn btn-primary">Submit ticket</button></form>';
$html.='<script src="js/tickets.js?version='.self::VERSION.'"></script>';
$this->displayContent($html.'</div>');
}
}
+14 -5
View File
@@ -3,9 +3,18 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
class RosterReports extends Base {
private function exportBudget(int $club,bool $financial=false): void {
(new TenantAccess())->requireManager($club);
(new ClubResources())->consume($club,'export');
$db=$this->connect();
if($financial){
BoundedList::exportGuard($db,'SELECT (SELECT COUNT(*) FROM club_dues WHERE club_serial=?)+(SELECT COUNT(*) FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE d.club_serial=?)+(SELECT COUNT(*) FROM club_due_adjustments a JOIN club_dues d ON d.due_serial=a.due_serial WHERE d.club_serial=?)',[$club,$club,$club]);
}else BoundedList::exportGuard($db,'SELECT COUNT(*) FROM club_memberships WHERE club_serial=?',[$club]);
BoundedList::$exporting=true;
}
public function ledger(): void {
$service=new RosterService();$service->requireAdministrator();$club=MaintenanceService::id($_POST['club_serial']??null);$name=$service->club($club)['club_name'];
$history=(new LedgerService())->history($club);header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-financial-history.csv"');$file=fopen('php://output','w');
$service=new RosterService();$service->requireAdministrator();$club=MaintenanceService::id($_POST['club_serial']??null);(new TenantAccess())->requireManager($club);$name=$service->club($club)['club_name'];
$this->exportBudget($club,true);$history=(new LedgerService())->history($club);header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-financial-history.csv"');$file=fopen('php://output','w');
$write=static function(array $row)use($file){fputcsv($file,array_map(static fn($v)=>self::csvText((string)$v),$row),',','"','');};
$write(['Record type','Club','Record ID','Dues ID','Member','Amount cents (USD)','Date','Method','Reference','Recorded by','Status / reason']);
foreach($service->dues($club) as $due)$write(['dues_total',$name,$due['due_serial'],$due['due_serial'],$due['user_first_name'].' '.$due['user_last_name'],$due['amount_cents'],'','','',$due['recorded_by']??'Historical','Paid cents '.$due['paid_cents'].'; balance cents '.($due['amount_cents']-$due['paid_cents'])]);
@@ -14,7 +23,7 @@ class RosterReports extends Base {
fclose($file);exit;
}
private function rows(): array {
$service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);$club=$service->club($id);
$service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);(new TenantAccess())->requireManager($id);$club=$service->club($id);$this->exportBudget($id);
$rows=[['Club','Username','First name','Last name','Email','Membership','Joined on']];
foreach($service->members($id) as $member)$rows[]=[$club['club_name'],$member['user_name'],$member['user_first_name'],$member['user_last_name'],$member['user_email'],$member['membership_status'],$member['joined_on']];
return $rows;
@@ -37,7 +46,7 @@ class RosterReports extends Base {
public function catalog(): void {
$service=new RosterService();$service->requireAdministrator();$html='<div class="container-fluid py-3"><h1>Roster reports</h1>';
if(!$service->ready()){$this->displayContent($html.'<p>Install the roster migration before exporting reports.</p></div>');return;}
foreach($service->clubs() as $club){$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.htmlspecialchars($club['club_name'],ENT_QUOTES,'UTF-8').'</h2><div class="d-flex gap-2">';foreach(['csv'=>'CSV','excel'=>'Excel','pdf'=>'PDF'] as $method=>$label)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.'.$method.'"><input type="hidden" name="club_serial" value="'.(int)$club['club_serial'].'"><input type="hidden" name="csrf_token" value="'.$this->csrfToken().'"><button class="btn btn-primary">Download '.$label.'</button></form>';$html.='</div></div></section>';}
$this->displayContent($html.'</div>');
foreach($service->clubs(BoundedList::page('clubs'),true) as $club){if(!$service->manages((int)$club['club_serial']))continue;$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.htmlspecialchars($club['club_name'],ENT_QUOTES,'UTF-8').'</h2><div class="d-flex gap-2">';foreach(['csv'=>'CSV','excel'=>'Excel','pdf'=>'PDF'] as $method=>$label)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.'.$method.'"><input type="hidden" name="club_serial" value="'.(int)$club['club_serial'].'"><input type="hidden" name="csrf_token" value="'.$this->csrfToken().'"><button class="btn btn-primary">Download '.$label.'</button></form>';$html.='</div></div></section>';}
$this->displayContent($html.BoundedList::navigation('clubs','RosterReports.catalog').'</div>');
}
}
+49 -31
View File
@@ -1,31 +1,43 @@
<?php
class RosterService extends Base {
public function administrator(): bool {return in_array($_SESSION[self::USER_ROLE]??'', ['Administrator','System Administrator'],true);}
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only site administrators may manage clubs and dues.',403);}
public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications')")->fetchColumn()===8;}
public function clubs(): array {
public function administrator(): bool {return (new TenantAccess())->managesAny();}
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only assigned club administrators may manage clubs and dues.',403);}
public function manages(int $club,?PDO $db=null): bool {return (new TenantAccess())->canManage($club,$db);}
public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications','club_invitations','club_tickets','club_ticket_messages')")->fetchColumn()===11 && (new TenantAccess())->available();}
public function clubs(?int $page=null,bool $managedOnly=false): array {
$db=$this->connect();
if($this->administrator())return $db->query('SELECT * FROM clubs ORDER BY club_name,club_serial')->fetchAll();
$stmt=$db->prepare("SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND c.club_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL ORDER BY c.club_name,c.club_serial");
$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
if((new TenantAccess())->isSuper($db))return BoundedList::rows($db,'SELECT * FROM clubs ORDER BY club_name,club_serial',[],$page,'clubs');
return BoundedList::rows($db,"SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND (c.club_status='active' OR m.membership_role='administrator') AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL".($managedOnly?" AND m.membership_role='administrator'":'')." ORDER BY c.club_name,c.club_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'clubs');
}
public function club(int $id,?PDO $db=null,bool $writing=false): array {
$db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch();
if(!$club)throw new AppException('Club not found.',404);
if(!$this->administrator()){
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]);
if(!$this->manages($id,$db)){
if($writing)throw new AppException('You cannot manage this club.',403);
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LOCK IN SHARE MODE");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]);
if($club['club_status']!=='active'||!$stmt->fetch())throw new AppException('You do not have access to this club.',403);
}
if($writing && $club['club_status']!=='active')throw new AppException('Reactivate the club before adding or changing records.',409);
return $club;
}
public function members(int $club): array {
$this->club($club);$stmt=$this->connect()->prepare("SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($this->administrator()?'':" AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial');$stmt->execute([$club]);return $stmt->fetchAll();
public function members(int $club,?int $page=null): array {
$this->club($club);$manage=$this->manages($club);
return BoundedList::rows($this->connect(),"SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($manage?'':" AND m.user_serial=? AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial',$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0],$page,'members');
}
public function seasons(int $club): array {$this->club($club);$stmt=$this->connect()->prepare('SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC');$stmt->execute([$club]);return $stmt->fetchAll();}
public function dues(int $club): array {
$this->club($club);$stmt=$this->connect()->prepare('SELECT d.*,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($this->administrator()?'':' AND m.user_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial');
$stmt->execute($this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
public function seasons(int $club,?int $page=null): array {
$this->club($club);return BoundedList::rows($this->connect(),'SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC',[$club],$page,'seasons');
}
public function member(int $club,int $membership): array {
$this->club($club);$manage=$this->manages($club);
$stmt=$this->connect()->prepare('SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.membership_serial=?'.($manage?'':" AND m.user_serial=? AND m.membership_status='active'"));
$stmt->execute($manage?[$club,$membership]:[$club,$membership,$_SESSION[self::USER_SERIAL]??0]);
$member=$stmt->fetch();if(!$member)throw new AppException('Membership not found in this club.',404);return $member;
}
public function dues(int $club,?int $page=null,?int $membership=null): array {
$this->club($club);$manage=$this->manages($club);
if($membership!==null)$this->member($club,$membership);
$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];if($membership!==null)$args[]=$membership;
return BoundedList::rows($this->connect(),'SELECT d.due_serial,d.club_serial,d.membership_serial,d.season_serial,d.created_at,d.recorded_by,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($manage?'':' AND m.user_serial=?').($membership===null?'':' AND d.membership_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial',$args,$page,'dues');
}
private function text(string $text,int $limit): string {$text=trim($text);if(!$text||strlen($text)>$limit)throw new AppException('A name is required and must fit the displayed length limit.');return $text;}
public static function date(string $value): string {$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)throw new AppException('Enter a valid date in YYYY-MM-DD format.');return $value;}
@@ -34,36 +46,42 @@ class RosterService extends Base {
[$whole,$fraction]=array_pad(explode('.',$value,2),2,'');return (int)$whole*100+(int)str_pad($fraction,2,'0');
}
public function saveClub(int $id,string $name,string $status='active'): int {
$this->requireAdministrator();$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
$db=$this->connect();$db->beginTransaction();
try {
if(!$id)(new TenantAccess())->requireSuper();else (new TenantAccess())->requireManager($id);$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
$db=$this->connect();
$id=DatabaseTransaction::run($db,function(PDO $db)use($id,$name,$status){
if(!$id)(new TenantAccess())->requireSuper($db);
$before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)throw new AppException('Club not found.',404);}
if($id)(new TenantAccess())->requireManager($id,$db);
if($id){$stmt=$db->prepare('UPDATE clubs SET club_name=?,club_status=? WHERE club_serial=?');$stmt->execute([$name,$status,$id]);}
else{$stmt=$db->prepare('INSERT INTO clubs(club_name,club_status) VALUES(?,?)');$stmt->execute([$name,$status]);$id=(int)$db->lastInsertId();}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));return $id;
});
return $id;
}
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined): void {
$this->requireAdministrator();$joined=self::date($joined);if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
$db=$this->connect();$db->beginTransaction();
try{
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined,string $memberRole='member'): void {
$this->requireAdministrator();(new TenantAccess())->requireManager($club);$joined=self::date($joined);if(!in_array($memberRole,['member','administrator'],true))throw new AppException('Invalid membership role.');if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
$db=$this->connect();
DatabaseTransaction::run($db,function(PDO $db)use($club,$membership,$identifier,$status,$joined,$memberRole){
// Lock user before membership writes, matching account deletion's lock order.
if($membership){$stmt=$db->prepare('SELECT user_serial FROM club_memberships WHERE membership_serial=? AND club_serial=?');$stmt->execute([$membership,$club]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('Membership not found in this club.',404);}
else{$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);}
else{(new TenantAccess())->requireSuper($db);$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);}
$stmt=$db->prepare('SELECT user_serial,user_status,user_email_verified_at FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);if(!($account=$stmt->fetch()))throw new AppException('Account not found.',404);
if($status==='active' && ($account['user_status']!=='active'||!$account['user_email_verified_at']))throw new AppException('The account must be active and its email verified before membership can be activated.',409);
$this->club($club,$db,true);
$before=null;if($membership){$stmt=$db->prepare('SELECT * FROM club_memberships WHERE membership_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$membership,$club]);$before=$stmt->fetch();if(!$before)throw new AppException('Membership not found.',404);}
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$membership,$club]);}
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,?,?)');$stmt->execute([$club,$user,$status,$joined]);}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'joined_on'=>$joined]));
$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
if(!(new TenantAccess())->isSuper($db) && (($before['membership_role']??'member')==='administrator'||$memberRole!=='member'))throw new AppException('Only the Super System Administrator can assign or change club administrators.',403);
if(($before['membership_role']??'')==='administrator' && $before['membership_status']==='active' && ($memberRole!=='administrator'||$status!=='active')){
$stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE club_serial=? AND membership_role='administrator' AND membership_status='active' ORDER BY membership_serial FOR UPDATE");$stmt->execute([$club]);if(count($stmt->fetchAll())<=1)throw new AppException('Assign another club administrator before removing the last one.',409);
}
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=?,membership_role=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$memberRole,$membership,$club]);}
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,?,?,?)');$stmt->execute([$club,$user,$status,$joined,$memberRole]);$membership=(int)$db->lastInsertId();}
if($status==='inactive'||($before&&$before['membership_role']!==$memberRole)){$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE club_serial=? AND invite_email=(SELECT user_email FROM users WHERE user_serial=?) AND invitation_status='pending'");$stmt->execute([$club,$user]);}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'membership_role'=>$memberRole,'joined_on'=>$joined]));
});
}
public function addSeason(int $club,string $name,string $start,string $end): void {
$this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)throw new AppException('Season end cannot precede its start.');
$db=$this->connect();$db->beginTransaction();try{$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
$db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$name,$start,$end){$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));});
}
public function charge(int $club,int $membership,int $season,string $amount): void {(new LedgerService())->charge($club,$membership,$season,$amount);}
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {(new LedgerService())->payment($club,$due,$amount,$paid,$key,$method,$reference);}
+7 -4
View File
@@ -3,7 +3,8 @@
class SignUp extends Base {
public function createAccount() {
$params = ['BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
$kind=$_POST['registration_kind']??(($_GET['membership']??'')==='invited'?'invited':'new_club');
$params = ['REGISTRATION_KIND'=>$kind,'BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
try {
@@ -11,20 +12,22 @@ class SignUp extends Base {
if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) {
throw new InvalidArgumentException('Too many account requests. Please try again later.');
}
$auth->register($_POST);
if(!in_array($kind,['new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.');
$input=$_POST;$input['registration_kind']=$kind;$auth->register($input);
try{(new EmailVerification())->request($_POST['user_email']);}
catch(Throwable $e){error_log('Signup verification delivery failed: '.$e->getMessage());}
$_SESSION['signup_success'] = true;
$_SESSION['signup_success'] = $kind;
header('Location: main.php?Xaction=signup', true, 303);
exit;
} catch (InvalidArgumentException $e) {
$params['SIGNUP_ERROR'] = $e->getMessage();
foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name'] as $field) {
foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name', 'club_name'] as $field) {
$params[strtoupper($field)] = $_POST[$field] ?? '';
}
}
}
if (!empty($_SESSION['signup_success'])) {
$params['REGISTRATION_KIND'] = $_SESSION['signup_success'];
$params['SIGNUP_SUCCESSFUL'] = 'TRUE';
unset($_SESSION['signup_success']);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/** Club permissions are looked up on each request, never trusted from form fields. */
class TenantAccess extends Base {
public const SUPER = 'Super System Administrator';
public function isSuper(?PDO $db = null, ?int $user = null): bool {
$stmt = ($db ?? $this->connect())->prepare("SELECT 1 FROM users WHERE user_serial=? AND user_role=? AND user_status='active' LOCK IN SHARE MODE");
$stmt->execute([$user ?? ($_SESSION[self::USER_SERIAL] ?? 0), self::SUPER]);
return (bool) $stmt->fetchColumn();
}
public function requireSuper(?PDO $db = null): void {
if (!$this->isSuper($db))
throw new AppException('Only a Super System Administrator may manage global accounts and settings.', 403);
}
public function canManage(int $club, ?PDO $db = null, ?int $user = null): bool {
$db ??= $this->connect();
$user ??= (int) ($_SESSION[self::USER_SERIAL] ?? 0);
if ($this->isSuper($db, $user))
return true;
$stmt = $db->prepare("SELECT 1 FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_role='administrator' AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LOCK IN SHARE MODE");
$stmt->execute([$club, $user]);
return (bool) $stmt->fetchColumn();
}
public function available(): bool {
return (bool) $this->connect()->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='club_memberships' AND COLUMN_NAME='membership_role'")->fetchColumn();
}
public function managesAny(): bool {
if ($this->isSuper())
return true;
if (!$this->available())
return false;
$stmt = $this->connect()->prepare("SELECT 1 FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_role='administrator' AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LIMIT 1");
$stmt->execute([$_SESSION[self::USER_SERIAL] ?? 0]);
return (bool) $stmt->fetchColumn();
}
public function requireManager(int $club, ?PDO $db = null): void {
if (!$this->canManage($club, $db))
throw new AppException('You cannot manage this club.', 403);
}
}