Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled

This commit is contained in:
2026-10-20 06:45:30 -04:00
parent 02223083f6
commit 1e63076123
116 changed files with 3252 additions and 2932 deletions
+53
View File
@@ -0,0 +1,53 @@
name: Security and workflow regressions
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
jobs:
regression:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
mariadb:
image: mariadb:11.4
env:
MARIADB_ROOT_PASSWORD: disposable-ci-password
ports: ['3306:3306']
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s --health-timeout=5s --health-retries=20
env:
HCR_ENVIRONMENT: Development
HCR_TEST_DSN: mysql:host=127.0.0.1;port=3306
HCR_TEST_USER: root
HCR_TEST_PASSWORD: disposable-ci-password
HCR_DB_HOST: 127.0.0.1
HCR_DB_PORT: 3306
HCR_DB_USER: root
HCR_DB_PASSWORD: disposable-ci-password
HCR_APPLICATION_URL: ''
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_mysql, dom, simplexml, xsl, mbstring, openssl, zip, gd, fileinfo, curl
tools: composer
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: composer install --no-interaction --prefer-dist
- run: composer check-platform-reqs
- run: composer audit --locked
- run: python -m pip install lxml==6.0.2
- run: sudo apt-get update && sudo apt-get install -y mariadb-client
- name: Install isolated test browser
run: |
npm install --prefix "$RUNNER_TEMP/hcr-browser" --ignore-scripts playwright@1.62.1
"$RUNNER_TEMP/hcr-browser/node_modules/.bin/playwright" install --with-deps chromium
echo "NODE_PATH=$RUNNER_TEMP/hcr-browser/node_modules" >> "$GITHUB_ENV"
echo "HCR_TEST_CHROME=$(node -p "require('$RUNNER_TEMP/hcr-browser/node_modules/playwright').chromium.executablePath()")" >> "$GITHUB_ENV"
- run: python tests/run.py
- run: python bin/build-release.py "$RUNNER_TEMP/hcr-release.zip"
+1
View File
@@ -2,3 +2,4 @@
/.env
/.tools/
/tests/.local/
/tests/__pycache__/
+4 -6
View File
@@ -1,10 +1,8 @@
Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports)(?:/|$) - [F,L,NC]
RewriteRule (^|/)\. - [F,L]
</IfModule>
<FilesMatch "^(composer\.(json|lock)|README\.md)$">
RewriteEngine On
RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports|deploy)(?:/|$) - [F,L,NC]
RewriteRule (^|/)\. - [F,L]
<FilesMatch "\.(?:json|lock|xml|sql|md|yml|yaml|toml|log|cnf|env|zip|bak)$">
Require all denied
</FilesMatch>
DirectoryIndex main.php
+5
View File
@@ -4,6 +4,11 @@ Login, signup, password changes, and password reset now use `classes/AuthService
The existing PHP/XSL presentation structure is retained. Signup creates an active
`User` account; it does not grant administrator access or club membership.
The October 19 update adds mailbox verification and reset-link invalidation on
account changes. Apply the newest schema before using that code; see
[production safeguard changes](PRODUCTION-FIXES.md) for migration and rollout
instructions. Ordinary club access now requires verified email ownership.
## Before using this version against your database
1. Back up the database and use a maintenance window for the schema update.
+253
View File
@@ -0,0 +1,253 @@
# Production safeguard changes — October 19, 2026
This implements the production review's security defects, financial history and
corrections, protected audit writes, mailbox verification, and deployment tooling.
It does not deploy to a public host or silently change the development database.
## Apply the new schema before running this code against an existing database
Back up the target database, verify that the backup restores, and use a maintenance
window. With separate migration credentials and the target environment configured:
```text
php bin/migrate-auth.php
php bin/migrate-roster.php
php bin/migrate-production.php
```
All three migrations are now applied to development. The production-safeguards
migration was applied there on October 20, 2026, after a fresh verified backup;
see the rollout record below. It has not been applied to a production host here. It adds
mailbox verification, financial correction/reversal tables, and payment metadata;
it enlarges journal entries, ensures transactional audit storage, and refreshes
the user view. It can be rerun. Existing financial records retain their amounts;
their previously unknown actors remain null and appear as historical records.
Schema changes can lock tables; they are CLI operations, never web requests.
Existing addresses are not automatically marked verified. Ordinary users must
verify their email before viewing club data or receiving/reactivating membership.
They can use the new verification flow or complete a password setup/reset link.
Existing administrators retain access to administer this transition. User records,
memberships, and financial history are preserved.
## Account security and browser dependency
- Password-reset issuance now locks the account and compares the originally
resolved email, status, and account version. Account edits and status changes
delete outstanding reset tokens under the same account lock. This handles both
interleavings: an edit before token creation, and an edit during email delivery.
- Username/email/role/status changes invalidate reset links and verification
links. Changing an email also clears its verified status. Disable/reactivate
cannot revive a reset link. Existing session revocation remains intact.
- Replaced jQuery 3.2.0 with official jQuery 3.7.1 on current and legacy templates.
Removed the old asset. Its downloaded SHA-256 matches the official integrity
value recorded in browser-dependencies.json. Existing jQuery 3.x plugin behavior
is retained; a major-version migration is not necessary for this fix.
- Browser tests cover account creation, deletion safeguards, the alert and popover
editors, rosters, financial corrections, downloads, and verification requests.
- Fixed the garbled username validation message and normalized newly added view
text to UTF-8.
## Financial history, corrections, and audit
- New LedgerService coordinates dues, payments, corrections, and reversals.
Charges and payments record their actor; payments also store method/reference.
- Itemized payment history shows date, amount, member, method/reference, actor,
and reversal details. Members see only their own financial history.
- Correcting dues appends an adjustment with the previous/effective amounts,
actor, reason, and request key. It preserves the original charge. A correction
cannot reduce dues below received payments; reverse an erroneous payment first.
- Reversing a payment appends one reversal with an actor and reason. It restores
the amount owed and retains the original payment. This records a bookkeeping
correction; it does not send a refund or move money.
- Repeated payment, correction, and reversal requests are idempotent. Changed
payloads with a reused request key are rejected. Concurrent writes use account,
club, and dues locks as appropriate; overpayment is still rejected.
- Financial CSV includes effective dues, paid/balance cents, individual payments,
corrections, reversal reasons, and actors. User text is protected against
spreadsheet formula injection. Monetary calculations use integer cents.
- Financial, club, membership, season, and administrative account changes write
audit entries in the same transaction. An audit failure rolls back the change.
Entries include entity IDs and relevant before/after values, amounts, or reasons.
Passwords and tokens are never audit payloads.
- Removed the journal-clearing route, button, and handler. The legacy PHP helper
also refuses clearing. Accounts with recorded journal history are retained;
disable them instead of deleting their identity.
- Runtime grant examples allow journal inserts but deny journal update/deletion
and financial update/deletion. Migrations and backup/restore use separate roles.
Authentication audit errors continue to go to server logs without preventing
login; configure alerts on those errors.
## Mailbox verification
- Signup requests an email verification link and explains how to resend it.
Public requests return neutral messages for unknown, verified, rate-limited,
or undeliverable accounts.
- Links contain 256-bit random values, stored only as hashes, expire after 24
hours, and are bound to the account email/version. Failed delivery removes the
unsent token. Expired records are handled by the cleanup CLI.
- Opening a link does not consume it. Confirmation requires POST, CSRF protection,
a valid token, and the account password. This prevents a mailbox owner from
accidentally activating an account whose password was chosen by someone else.
- Successful password setup/reset also proves ownership of its current mailbox.
Changing a known password without a delivered token does not verify an email.
- Active membership assignment requires an active, verified account. Regular
members also need verified email to read club data. Admin summaries show the
verification state and retain the password setup-link action.
## Production configuration and release tooling
- Remote production database connections require HCR_DB_TLS_CA. Certificate
validation is enabled and the negotiated TLS cipher is checked. Localhost/
loopback database connections remain available; explicitly protect any local
tunnel. Production TLS must still be tested against the actual server.
- Set HCR_ENVIRONMENT=Production explicitly. That prevents automatic loading of
settings.local.xml; environment variables or an explicitly selected external
HCR_SETTINGS_FILE supply production secrets. Never package development secrets.
- HTTPS is recognized directly or from X-Forwarded-Proto only when REMOTE_ADDR is
in HCR_TRUSTED_PROXIES, a comma-separated list of exact IP addresses. The proxy
must overwrite forwarded headers and direct access to its backend must be
restricted. Untrusted and ambiguous forwarding headers are rejected.
- Application responses hide PHP errors and send CSP, frame/referrer/content-type
protections and Permissions-Policy. Production HTTPS sends HSTS. Inline scripts
and JavaScript links were replaced so script-src does not need unsafe-inline.
Inline styles remain permitted for Bootstrap/editor compatibility.
- Apache protections now require mod_rewrite rather than silently skipping the
private-directory rules. Apache needs AllowOverride support. IIS additionally
blocks sensitive file extensions. Nginx and PHP configuration examples are in
deploy/. These examples must be adapted and tested on the chosen server.
- `python bin/build-release.py /protected/releases/hcr.zip` creates a clean zip and
SHA-256 manifest from the workspace, including new files. It excludes local
settings, backups, tests, Git metadata, and IDE files. Use locked Composer
dependencies and review the archive before deployment; it is not published.
- `python bin/check-production.py https://app.myhuntingclubroster.com` performs
read-only HTTPS, header, cookie, and private-path checks after deployment.
It sends no email and changes no accounts. The actual production host was not
contacted in this change.
- `php bin/check-health.php` exits nonzero on database/schema failures. Run it
from the server monitor with production settings and alert on failures.
## Operational setup on the hosting server
The project now supplies the entry points; the server administrator must install
their schedule, permissions, storage, and alerts on the actual production host.
No hosting credentials or host details were supplied for that work.
1. Provision migration, runtime, and backup accounts separately. Adapt
deploy/runtime-grants.sql.example. Keep secrets outside the document root and
restrict file access to the service/job accounts. Test the runtime role.
2. Schedule `php bin/cleanup-auth.php` daily. It removes expired reset/verification
links and stale rate-limit buckets without deleting audit history.
3. Schedule `php bin/backup-database.php /protected/backups` using backup-specific
credentials. Set HCR_DUMP_BINARY to the installed mariadb-dump executable if it
is not on PATH. The directory must exist outside the application tree. The job
writes a complete transactional SQL dump and checksum metadata and removes its
temporary credentials file. All tables must be InnoDB. It exits on failure.
4. Restrict the backup directory before running the job, especially with Windows
ACLs. Use encrypted storage and protected off-host copies, define retention and
recovery objectives, and regularly restore-test backups. The script does not
implement encryption, retention, or off-host storage by itself.
5. Alert on failed backups/cleanup/health jobs, PHP errors, audit failures, SMTP
failures, disk pressure, and database failures. Configure log rotation and
protected audit retention/archival; there is no web audit-deletion facility.
6. Run Composer platform checks and the isolated regression runner before release.
The GitHub workflow supplies a disposable MariaDB and browser; it runs when
pushed to a repository with Actions enabled. This change does not push code or
claim that GitHub CI has run.
7. Deploy the complete release during the migration window. Run host checks, then
send a real invitation/reset to a controlled mailbox and verify inbox delivery
and link completion. Local tests deliberately use simulated delivery.
8. Keep the prior release and the verified pre-migration backup. Recovery after
new financial writes needs reconciliation; do not blindly restore an old dump
and discard subsequently received entries.
## Verification and remaining scope
The final local run passed **308 checks plus 5 concurrency scenarios**:
| Suite | Passed |
| --- | ---: |
| Authentication service/templates | 49 |
| Maintenance validation/pagination | 52 |
| Authentication HTTP | 27 |
| Project HTTP, ledger, exports, and access boundaries | 99 |
| Reset invalidation, verification, corrections, audit rollback, transport | 42 |
| Restricted runtime database permissions | 5 |
| Browser workflows, editors, corrections, downloads, and CSP | 21 |
| Migration, release, backup, health, and private-path tooling | 13 |
| Concurrent defaults, payments, reversal retries, correction retries, final admin | 5 scenarios |
Source checks passed for 41 PHP files, 55 JavaScript files, 39 compiled XSL
stylesheets, and the jQuery integrity hash. IIS configuration is well-formed XML.
Composer's final locked dependency audit reported no advisories or abandoned
packages. The GitHub workflow and actual Apache/IIS/Nginx production hosts have
not been executed or contacted here.
The verified development backup was also restored locally. After bringing the
copy to the existing roster schema, the new migration was run twice. Every
original column value across all 17 existing tables was unchanged. The isolated
restored copy was removed. That October 19 implementation run did not migrate
development; the subsequently authorized rollout is recorded below.
Tests use generated disposable databases; they do not send real mail, move money,
or modify existing development accounts. Temporary databases and the restricted
test account were removed, and the local test server was stopped. Production TLS
certificate negotiation and real inbox delivery require the actual hosting setup.
Global site administrators continue to manage all clubs. Active club members
continue to see their club's active contact directory. These are existing product
policies, not newly introduced club-admin roles or contact-consent settings.
Delegated club administration, MFA enrollment/recovery, online payments/refunds,
and large-scale pagination/background exports remain separate enhancements. The
production-specific TLS, web-server configuration, monitoring schedules, off-host
backup policy, and real mail delivery remain deployment acceptance work.
## Development rollout — October 20, 2026
At the user's request, `bin/migrate-production.php` was applied to
`192.168.1.190/huntingclubroster` at 6:32 a.m. Eastern. The previously approved
unencrypted development connection was used. The target was checked explicitly;
no production host was changed.
Before migration, a complete 43,100-byte backup was saved at
`.tools/backups/20261020-063143/huntingclubroster.sql`. Its SHA-256 is
`526113c2575927d33680cb696a398f59352554a5892c23c1a8ff414fe6f1e3ca`.
The backup was restored locally and its checksum and all 17 table counts matched.
Every existing column value in the original 17 development tables was checked
before and after migration and was preserved. The database now has 20 base tables.
**48 acceptance checks passed against development**, using temporary accounts and
an owned temporary club through a local application server. They verified:
- Verification link generation, trusted URL, POST confirmation, correct-password
enforcement, single use, stored verification state, and membership gating.
- Password reset, old-password rejection, new-password login, consumed-token
rejection, session revocation, and invalidation after an email change.
- Clearing verified state on email change and restoring club access only after a
fresh bound verification link is confirmed.
- Dues corrections preserving original charges, safe retries, correct balances,
and rejection of corrections below received payments or excess payments.
- Reversals preserving original payments, safe retries, duplicate-reversal
rejection, restored balances, and settlement with a replacement payment.
- Ordinary-member restrictions, detailed financial CSV, and audit events.
Email issuance used the real application services and development database, with
only the delivery boundary replaced by local capture. No real emails were sent;
SMTP/inbox delivery was not retested. The public URL was checked in generated
links without requiring development to be served at that public hostname. All
payments were manual test ledger entries; no money moved.
The first helper attempts encountered a Windows temporary-session-path parsing
issue. The helper was corrected and those temporary records were removed. During
the first complete run, an independent administrator login changed existing login
timestamps, a journal entry, and rate counters. The differences were proved to
match that login and were preserved; no original data was restored over user
activity. A final complete run finished with a clean baseline comparison across
all 20 tables. Existing accounts were not used as test accounts or reset by the
verification scripts.
All temporary accounts, memberships, seasons, charges, payments, adjustments,
reversals, tokens, test audit/rate-limit entries, and temporary sessions were
removed. Auto-increment sequences may have advanced. A final CLI health check
passed. The local restore copy was removed and its database server stopped.
Private migration results and the check-by-check report remain beside the backup.
+171
View File
@@ -0,0 +1,171 @@
# Production readiness review — October 18, 2026
Follow-up implementation: see [PRODUCTION-FIXES.md](PRODUCTION-FIXES.md) for the
October 19 code changes, new migration, tests, and remaining host acceptance work.
The findings below describe the reviewed version before that implementation.
Recommendation: hold production launch until the security defects and financial
recordkeeping gaps below are resolved and the production deployment checks pass.
The recent successful workflow tests remain useful, but do not cover these gaps.
This review inspected the current source, schema, server rules, configuration,
dependencies, and deployment documentation. Composer's locked dependency audit
returned no published advisories or abandoned packages. One security defect was
reproduced using a disposable local database; that database was removed and its
server stopped. No development database records or application code were changed
during this review. The earlier 231-check suite and 54 development checks were
not rerun as part of this review.
## 1. P1 — Revoke reset links when account security details change
Evidence: `classes/MaintenanceService.php:110–137` revokes sessions on account
changes but leaves password reset tokens intact. `classes/AuthService.php:150–181`
accepts a token based on its user ID and expiry without binding it to the account
version or email that existed when it was issued.
Confirmed reproduction: issue a valid token for `old@example.test`, change the
account email to `new@example.test` through MaintenanceService, then submit the
old token. The reset succeeds. Someone holding a link delivered to the old mailbox
can therefore regain access while the token remains valid, up to 30 minutes.
Fix: invalidate existing tokens transactionally on email and other security changes;
bind issued tokens to the relevant account version so concurrent issuance cannot
leave an old token valid. Cover email changes, disable/reactivate, role changes,
and concurrent reset issuance with regression tests.
## 2. P1 — Replace the vulnerable browser dependency
Evidence: `xsl/content.xsl:41`, `xsl/login.xsl:36`, and
`xsl/createAccount.xsl:37` load `js/jquery-3.2.0.min.js`.
This version predates fixes for published cross-site scripting vulnerabilities in
HTML manipulation. The upstream advisories describe vulnerable operations and
affected versions; this review does not claim a demonstrated application exploit.
Composer audit does not cover bundled JavaScript files.
Fix: select a patched, supported jQuery version, review the bundled plugins for
compatibility, and rerun browser tests. Track browser dependencies in a manifest
and include them in dependency checks.
Primary sources:
- https://github.com/jquery/jquery/security/advisories/GHSA-gxr4-xjj5-5px2
- https://github.com/jquery/jquery/security/advisories/GHSA-jpcq-cgw6-v4j6
- https://jquery.com/upgrade-guide/3.5/
## 3. P1 — Complete financial history and corrections before real dues use
Evidence: `classes/Roster.php:66–71` displays charges, summed payments, and balances,
but no itemized payment history. `classes/RosterService.php:62–79` supports inserting
charges and payments, without a correction, void, or reversal workflow.
`migrations/002_roster.sql:30–49` has no recorded-by actor, correction reason, or
payment reference fields.
A payment or charge entered incorrectly currently requires direct database work.
The user also cannot inspect each payment's date and details in the application.
Fix: add itemized payment history and financial export/reconciliation; record who
entered each item and a reference/method where appropriate; implement permissioned,
audited adjustments or reversals that preserve the original entry. Test balance
recalculation, repeated requests, and concurrent corrections. This concerns the
manual ledger already in scope; online payment processing is a separate feature.
## 4. P1/P2 — Preserve and complete the audit trail
Evidence: `classes/Journal.php:38–44` lets an administrator truncate the complete
journal. `classes/RosterService.php:44–61` does not audit membership or season
changes. Financial messages at lines 67 and 77 omit amounts and the new financial
record's ID. `classes/Audit.php:3–7` catches write failures; financial writes commit
before their audit messages are attempted. Administrative account changes also
lack corresponding detailed audit entries.
Consequences: changing club access may leave no journal event, and the journal
can be erased. A financial save can succeed while its audit record is lost.
Fix: remove unrestricted journal clearing, define retention and protected archival,
record actor/entity/before-and-after details for privileged changes, and write
financial audit events in the same transaction or a transactional outbox. Keep
authentication available if its logging backend fails, with failure monitoring.
## 5. P2 — Verify email ownership before relying on it for club access
Evidence: `classes/AuthService.php:86–113` immediately activates a self-registered
account without verifying its mailbox. `classes/RosterService.php:44–55` assigns
membership to an existing username or email with no verified-email check.
An account can be registered using someone else's email. Signup alone does not
grant membership, but an administrator assigning that expected email could select
an account controlled by the wrong person.
Fix: implement mailbox verification or an invitation acceptance flow, and require
verified identity before assignment by email. Email changes should require fresh
verification and invalidate outstanding reset links. Until then, an explicit
independent identity-check procedure is needed for assignments.
## 6. Release gate — Verify production isolation, transport, and secrets
These are deployment checks, not claims that a public leak currently exists.
- `classes/Base.php:145–153` has no database TLS certificate/verification options.
The approved non-TLS development connection does not establish a production
transport design. If production uses a remote database, configure verified TLS
or an explicitly secured private transport; use a restricted runtime database
account and a separate migration account.
- `classes/Base.php:432–440` automatically prefers `xml/settings.local.xml` before
environment overrides. Build a clean production artifact that excludes local
secrets, `.tools` backups and control files, `.git`, tests, and IDE files.
Current workspace changes include untracked application classes and migrations;
ensure the release captures every required file.
- Apache private-directory rules depend on rewrite configuration. IIS and the
development router have different rules; no Nginx configuration is provided.
Verify private paths and backup filenames return a denial on the actual host.
Prefer placing private code, secrets, and backups outside the document root.
- `main.php:20–30` detects HTTPS only through `$_SERVER['HTTPS']`. If TLS terminates
at a proxy, configure a trusted proxy/server arrangement; otherwise HTTPS
visitors can be rejected or cookies can be misconfigured. Do not trust arbitrary
client-supplied forwarding headers.
- Confirm production environment mode, secure cookies, PHP error-display settings,
HTTPS enforcement, and secret provisioning. Add HSTS at the HTTPS server and a
tested Content Security Policy; the latter needs work on inline scripts and
existing JavaScript links.
- Perform a production-host invitation/reset test using the configured application
URL and sender. Earlier SMTP acceptance and local tests do not establish inbox
delivery or the final hosting setup. This review did not test production DNS.
## 7. Release gate — Make backup, monitoring, and deployment repeatable
The verified manual backups prove a backup can be restored; the repository does
not demonstrate an automated production backup/restore policy, alerting setup, or
release pipeline. These may exist outside the project and should be verified.
Before launch:
- Schedule protected backups outside the application document root, define
retention and recovery objectives, and periodically restore-test them.
- Monitor application failures, audit failures, SMTP delivery, database health,
disk capacity, and failed backup jobs.
- Schedule `bin/cleanup-auth.php` as documented; ensure scheduled jobs use the
correct environment and credentials.
- Create a reproducible release with platform-requirement checks, dependency
audits, isolated regression tests, migration verification, and a recovery plan.
- Test representative roster sizes. Club rosters/dues and exports currently load
complete result sets; large clubs need paging, query/performance testing, and
export limits or background generation as appropriate.
## Product decisions and lower-priority improvements
- All club management currently belongs to global site administrators, who can
access every club. This matches the implemented scope. If each club will manage
itself, scoped club-administrator roles and cross-club authorization tests are
needed before that operating model launches.
- Active members can see their club's other active members' email addresses
(`RosterService.php:22–24`). Confirm that this is the intended directory/privacy
policy; consider restricted visibility or opt-in contacts.
- Add MFA and recent-password confirmation for sensitive administrator actions.
These are additional protections, not a claim that current session checks fail.
- Add season editing with history, clearer payment error recovery, keyboard/mobile
usability checks, and repair the garbled username validation text in
`AuthService.php:95`. These should follow the security and ledger work.
Recommended order: reset-token invalidation; browser dependency update; durable
financial history/corrections and audit; verified identity; production deployment
and operational verification. Resolve the club-administration and directory-privacy
decisions before enabling real club users.
+194
View File
@@ -0,0 +1,194 @@
# Project repairs and multi-club setup
Implemented and tested October 18, 2026. Existing authentication work is described
in [AUTHENTICATION.md](AUTHENTICATION.md).
## Database rollout
The roster migration was applied to the development database on October 18, 2026,
after the implementation tests and a fresh, verified backup. The development
verification is recorded below. Existing accounts and passwords were preserved.
After backing up the target database, run from the project directory with the
application's database configuration and PHP runtime:
```text
php bin/migrate-roster.php
```
The migration adds five InnoDB tables: clubs, memberships, seasons, dues, and
payments. It preserves existing records, can be rerun, and uses foreign keys to
prevent orphan records and cross-club dues assignments. It does not rebuild the
database. The authentication migration remains a prerequisite for this version;
it was already applied to development during the earlier authentication work.
The existing backup was restored locally, its SHA-256 and all ten original table
counts were checked, then both migrations were rehearsed. Running the roster
migration twice preserved all ten original counts. The restored copy was removed.
On other installations, until the roster migration is applied, the home page
explains that setup is needed.
Use the PHP version and Composer requirements in AUTHENTICATION.md. Report tests
used the curl, zip, gd, fileinfo, mbstring, openssl, PDO MySQL, and XSL extensions.
Run Composer's platform requirements check with the web server's PHP configuration.
## Security and deletion safeguards
- Dropdown usage metadata no longer selects arbitrary PHP callbacks or SQL
identifiers. Only the supported no-reference rule is accepted; unsupported
legacy rules fail closed rather than exposing settings or allowing deletion.
- IDs and booleans are validated, and dropdown writes use bound parameters.
A dropdown must belong to the requested type and be active to become default.
Switching defaults is transactional and serialized by type. Deactivation clears
the default. Default dropdowns and types containing dropdowns cannot be deleted.
- Account deletion, disablement, and role changes protect the last active
administrator, including simultaneous requests. Administrators cannot delete
themselves; ordinary administrators cannot modify System Administrator accounts.
Accounts with notes or club memberships must be retained. Reset tokens are
removed transactionally when an otherwise unused account is deleted.
- Status saves validate fields and enforce at most one default; inactive defaults
and deletion of the current default are rejected. Removed dependencies on
nonexistent requests and workorders tables from usage checks.
- Notes validate type, text, target, and existing note on the server. Target user
locks coordinate note creation with account deletion. Queries are bound and
displayed note text is escaped.
- Routing allows only supported actions, with administrative access enforced on
maintenance and exports. Removed arbitrary helper dispatch and obsolete report
dispatch. Failures return meaningful HTTP statuses and escaped messages;
AJAX no longer treats an empty successful write as invalid JSON.
## Accounts, lists, navigation, and browser behavior
- Administrative account creation validates first and last names, username,
email, and role. It stores an unknown random hashed password and sends a setup
link. A mail failure reports that the account exists and offers a resend path;
retrying cannot silently duplicate the account. No temporary password is exposed.
- Repaired account summaries, notes, and login history without the missing
Subscriptions dependency. Corrected username lookup and invitation resend.
- Restored audit entries for successful login, reset requests, password resets,
and password changes; club saves, dues, and payments also write journal entries.
Audit write errors are logged without breaking authentication.
- Pagination now uses correct last-page offsets, stable ordering, bounded page
sizes, empty-list handling, and independent state per list. Fixed incorrect
per-page fields, added status page-size refresh, escaped search patterns, and
added last-name account search and display.
- Replaced dead permit, zoning, subscription, support-ticket, and payment links
with Clubs and dues, Help, and a working report catalog. Retired three legacy
permit report implementations and obsolete metrics dependencies.
- Converted synchronous browser requests to asynchronous requests and fixed their
callers. Save/confirmation controls wait for requests, errors appear visibly,
and failed deletion checks never open a confirmation dialog. Default-checkbox
failures restore the prior selection.
- Hidden form inputs are built as DOM text values; toast and error content use
text rendering. Cached form restoration excludes hidden routing fields, IDs,
CSRF tokens, and passwords. Fixed ZIP lookup routing, logout request handling,
tooltip initialization, and jQuery callback compatibility.
## Multi-club roster features
- Site administrators create, rename, archive, and reactivate multiple clubs.
They assign existing accounts to separate club memberships, maintain membership
status and joined dates, and add dated seasons.
- Ordinary members see active clubs to which they have active membership, their
club's active contact roster, and only their own dues. Changing a URL or form ID
cannot grant access to another club. Membership changes take effect in existing
sessions. Signup alone grants no club membership.
- Administrators record season dues and received payments in a **manual USD
ledger**. Amounts use integer cents, reject fractional cents and negative values,
and payments cannot exceed the remaining balance. Repeated payment submissions
use an idempotency key to avoid duplicate entries. Dates and club relationships
are checked on the server.
- Archiving retains membership and financial history and prevents further writes
until reactivation. History is not exposed through destructive delete controls.
- Administrators can export a club roster as CSV, Excel, or PDF. Spreadsheet
formula injection is neutralized, Excel cells are explicit text, and PDF fields
are escaped.
This is a multi-club foundation managed by global site administrators. It does not
provide delegated club administrators, online payment processing, refunds, ledger
correction workflows, or automatic billing. These are separate features.
## Verification
All final suites passed against generated disposable local databases:
| Suite | Result |
| --- | --- |
| Authentication service and templates | 49 checks |
| Authentication HTTP routes | 27 checks |
| Maintenance validation and pagination units | 52 checks |
| Project HTTP, access boundaries, ledger, exports, and paging | 88 checks |
| Headless Chrome workflows | 15 checks |
| Concurrent default and administrator updates | 2 scenarios |
Total: **231 checks plus 2 concurrency scenarios**. Browser tests verified account
creation, visible invitation failure, account summary, blocked and successful
deletion, restricted dropdown metadata, club/membership/season creation, dues,
payments, and CSV download without unexpected JavaScript errors. Mail failure was
deliberately simulated; this round did not retest real delivery.
Source checks also passed for 24 application PHP classes, four CLI scripts, four
PHP test scripts, main.php, 54 JavaScript files, and 39 compiled XSL stylesheets.
All disposable databases were cleaned up and the isolated database server stopped.
Tests require an explicitly configured isolated MySQL/MariaDB server through
`HCR_TEST_DSN`, `HCR_TEST_USER`, and `HCR_TEST_PASSWORD`. HTTP tests additionally
require matching `HCR_DB_HOST`, `HCR_DB_PORT`, `HCR_DB_USER`, and `HCR_DB_PASSWORD`.
`PHP_TEST_COMMAND` accepts a JSON argument array for the test PHP runtime.
`NODE_TEST_COMMAND` selects Node and `NODE_PATH` locates Playwright for browser
tests. Do not point these tests at the application database.
```text
php tests/auth_integration.php
php tests/maintenance_unit.php
python tests/auth_http.py
python tests/project_http.py
python tests/concurrency.py
python tests/browser_runner.py
```
These tests cover application behavior in isolation; deployment still needs the
new migration on any other target and validation with its actual PHP, HTTPS, and
mail configuration.
## Development database rollout and verification
At the user's request, the roster migration was applied to
`192.168.1.190/huntingclubroster` on October 18, 2026 at 8:23 p.m. Eastern.
The previously approved unencrypted database connection was used.
Before migration, a fresh complete SQL backup was saved at
`.tools/backups/20261018-202150/huntingclubroster.sql` (37,317 bytes). Its SHA-256 is
`f4458cce64404063d9760c2fe06f9779cf8904c0dc27d301541b24486382157a`.
The backup was restored on an isolated local server; the checksum and row counts
for all 12 original tables matched. All existing rows were fingerprinted before
and after migration and were unchanged. Five new roster tables are ready.
Then **54 checks passed against the development database**, through a local HTTP
application server using three temporary accounts, two temporary clubs, separate
memberships, three dues records, and received payments. The checks covered:
- Creating multiple clubs, seasons, and memberships, including one account in two
clubs and rejection of duplicate memberships and dues.
- Membership and season boundaries, member financial privacy, administrative
permissions, CSRF enforcement, and protection against account deletion.
- Exact cents, partial payments, correct balances, idempotent payment retries,
changed-key-payload rejection, overpayment rejection, and full settlement.
- Immediate access revocation for inactive membership or archived clubs, blocked
writes while archived, and successful reactivation with preserved ledger data.
- Valid CSV, Excel, and PDF downloads.
An initial verification-helper session setup error was corrected before the
successful run; its temporary records were also removed. This required no change
to application code.
All temporary accounts, club records, dues, payments, test audit entries, rate
limit records, and sessions were removed afterward. Every row across all 17
tables matched the pre-test fingerprint. Auto-increment sequences may have
advanced during testing. No existing passwords were changed and no emails or
online payment transactions were sent. These are manually recorded payments.
Private backup metadata, migration results, and the check-by-check verification
report are retained alongside the backup in `.tools/backups/20261018-202150/`.
That directory is ignored by Git and blocked by the development router. The local
restore copy was removed and the isolated local database server stopped.
+6
View File
@@ -3,3 +3,9 @@
See [authentication setup and changes](AUTHENTICATION.md) before deploying the
updated login, signup, and password reset flows. The authentication migration
must be applied to the existing database before using this version.
See [project repairs and multi-club setup](PROJECT-REPAIRS.md) for the security
fixes, new roster migration, feature scope, and verification results.
See [production safeguard changes](PRODUCTION-FIXES.md) for the newest migration,
verified-email requirement, ledger corrections, and deployment instructions.
+28
View File
@@ -0,0 +1,28 @@
<?php
/** Scheduler entry point. Credentials are never passed on the process command line. */
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require __DIR__.'/../classes/Base.php';
$directory=realpath($argv[1]??'');$root=realpath(__DIR__.'/..');
if(!$directory||!is_dir($directory)||strtolower($directory)===strtolower($root)||str_starts_with(strtolower($directory),strtolower($root).DIRECTORY_SEPARATOR))throw new RuntimeException('Choose an existing protected backup directory outside the application tree.');
$base=new Base();$settings=$base->getSettings();$db=$base->connect();
$engines=$db->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
if(array_filter($engines,static fn($v)=>$v!=='InnoDB'))throw new RuntimeException('Nontransactional tables require a maintenance-window locked backup.');
$file=$directory.DIRECTORY_SEPARATOR.'hcr-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.sql';
$partial=$file.'.partial';$handle=fopen($partial,'x');if(!$handle)throw new RuntimeException('Cannot create protected backup output.');fclose($handle);chmod($partial,0600);
$config=tempnam($directory,'hcr-connection-');chmod($config,0600);
$quote=static fn($value)=>'"'.str_replace(["\\",'"',"\r","\n"],["\\\\",'\\"','\\r','\\n'],(string)$value).'"';
$options="[client]\nhost=".$quote($settings->DatabaseServer)."\nport=".(int)($settings->DatabasePort??3306)."\nuser=".$quote($settings->DatabaseUser)."\npassword=".$quote($settings->DatabasePassword)."\n";
$ca=getenv('HCR_DB_TLS_CA');if($ca)$options.='ssl-ca='.$quote($ca)."\nssl-verify-server-cert\n";
try{
file_put_contents($config,$options);
$command=[getenv('HCR_DUMP_BINARY')?:'mariadb-dump','--defaults-extra-file='.$config,'--single-transaction','--quick','--routines','--events','--triggers','--hex-blob','--databases',(string)$settings->DatabaseName,'--result-file='.$partial];
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
if(!is_resource($process))throw new RuntimeException('Backup process could not start.');
fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
if(proc_close($process)!==0){error_log('Backup process failed: '.$error);throw new RuntimeException('Backup failed; inspect the protected backup error log.');}
if(!is_file($partial)||filesize($partial)<100||!str_contains(file_get_contents($partial),'Dump completed on'))throw new RuntimeException('Backup completion could not be confirmed.');
if(!rename($partial,$file))throw new RuntimeException('Cannot finalize completed backup.');
chmod($file,0600);
$metadata=['created_utc'=>gmdate(DATE_ATOM),'bytes'=>filesize($file),'sha256'=>hash_file('sha256',$file)];file_put_contents($file.'.json',json_encode($metadata,JSON_PRETTY_PRINT));chmod($file.'.json',0600);
echo json_encode(['backup'=>$file]+$metadata),"\n";
}finally{if(is_file($config))unlink($config);if(is_file($partial))unlink($partial);}
+27
View File
@@ -0,0 +1,27 @@
"""Build a clean deployment zip from current workspace files, including new files."""
import argparse,hashlib,json,zipfile
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser();parser.add_argument('output',type=Path);args=parser.parse_args()
output=args.output.resolve()
if output.exists():raise SystemExit('Output already exists; choose a new release filename.')
files=[]
for directory in ['classes','css','images','js','html','migrations','reports','vendor','views','xsl']:
for file in (ROOT/directory).rglob('*'):
if file.is_file() and not file.is_symlink() and file.suffix.lower() not in ['.sql','.php','.js','.css','.xsl'] and directory not in ['vendor','images','css','html']:continue
if file.is_file() and not file.is_symlink() and not any(p.startswith('.') for p in file.relative_to(ROOT).parts) and '_orig.' not in file.name:files.append(file)
for name in ['main.php','.htaccess','web.config','composer.json','composer.lock','xml/settings.xml','xml/constants.xml']:
files.append(ROOT/name)
for name in ['migrate-auth.php','migrate-roster.php','migrate-production.php','cleanup-auth.php','check-production.py','backup-database.php','check-health.php']:
file=ROOT/'bin'/name
if file.exists():files.append(file)
if not (ROOT/'vendor/autoload.php').is_file():raise SystemExit('Install locked Composer dependencies before building a release.')
manifest={file.relative_to(ROOT).as_posix():hashlib.sha256(file.read_bytes()).hexdigest() for file in sorted(set(files))}
for name in manifest:
if name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml'):raise SystemExit('Private file would enter the release.')
output.parent.mkdir(parents=True,exist_ok=True)
with zipfile.ZipFile(output,'x',zipfile.ZIP_DEFLATED) as archive:
for name in manifest:archive.writestr(name,(ROOT/name).read_bytes())
archive.writestr('release-manifest.json',json.dumps(manifest,indent=2))
print(json.dumps({'release':str(output),'files':len(manifest),'sha256':hashlib.sha256(output.read_bytes()).hexdigest()}))
+9
View File
@@ -0,0 +1,9 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require __DIR__.'/../classes/Base.php';
try{
$db=(new Base())->connect();$db->query('SELECT 1')->fetchColumn();
if(!(new RosterService())->ready())throw new RuntimeException('Required schema is missing.');
foreach(['user_email_verified_at'] as $column){$columns=$db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);if(!in_array($column,$columns,true))throw new RuntimeException('Account schema is missing.');}
echo "Database and required application schema are available.\n";
}catch(Throwable $e){fwrite(STDERR,"Health check failed. Review the protected server logs.\n");error_log((string)$e);exit(1);}
+22
View File
@@ -0,0 +1,22 @@
"""Read-only checks of a deployed HTTPS host; never sends account email or writes data."""
import argparse,http.cookiejar,ssl,urllib.error,urllib.parse,urllib.request
parser=argparse.ArgumentParser();parser.add_argument('url');args=parser.parse_args()
url=args.url.rstrip('/');parts=urllib.parse.urlsplit(url)
if parts.scheme!='https' or parts.query or parts.fragment or parts.username:raise SystemExit('Use the trusted HTTPS application base URL.')
client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),urllib.request.HTTPSHandler(context=ssl.create_default_context()))
def get(path):
try:
with client.open(url+'/'+path,timeout=25) as response:return response.status,response.headers,response.read(),response.geturl()
except urllib.error.HTTPError as error:return error.code,error.headers,error.read(),error.geturl()
failures=[]
status,headers,body,final=get('main.php')
if status!=200 or urllib.parse.urlsplit(final).scheme!='https':failures.append('HTTPS login page unavailable.')
for name in ['Strict-Transport-Security','Content-Security-Policy','X-Content-Type-Options','Referrer-Policy','X-Frame-Options']:
if not headers.get(name):failures.append('Missing '+name)
cookie=';'.join(headers.get_all('Set-Cookie') or [])
if not all(flag in cookie.lower() for flag in ['secure','httponly','samesite=lax']):failures.append('Session cookie flags are missing.')
for path in ['xml/settings.local.xml','xml/settings.xml','.tools/backups/probe.sql','.git/config','vendor/autoload.php','bin/migrate-production.php','classes/Base.php','tests/http_fixture.php','composer.lock','release-manifest.json']:
if get(path)[0] not in [403,404]:failures.append('Private path was not denied: '+path)
for failure in failures:print('FAIL: '+failure)
if failures:raise SystemExit(1)
print('HTTPS, security headers, cookies, and private-path checks passed. Verify inbox delivery and operational jobs separately.')
+1
View File
@@ -3,5 +3,6 @@ if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
require_once __DIR__ . '/../classes/Base.php';
$db = (new Base())->connect();
$db->exec('DELETE FROM password_resets WHERE expires_at < UTC_TIMESTAMP()');
$db->exec('DELETE FROM email_verifications WHERE expires_at < UTC_TIMESTAMP()');
$db->exec('DELETE FROM auth_attempts WHERE window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)');
echo "Expired authentication records removed.\n";
+22
View File
@@ -0,0 +1,22 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
foreach([
'users'=>['user_email_verified_at'=>'DATETIME NULL'],
'club_dues'=>['recorded_by'=>'BIGINT UNSIGNED NULL'],
'club_payments'=>['recorded_by'=>'BIGINT UNSIGNED NULL','payment_method'=>"VARCHAR(20) NOT NULL DEFAULT 'unspecified'",'payment_reference'=>"VARCHAR(100) NOT NULL DEFAULT ''"],
] as $table=>$columns){
$existing=$db->query('SHOW COLUMNS FROM `'.$table.'`')->fetchAll(PDO::FETCH_COLUMN);
foreach($columns as $column=>$definition)if(!in_array($column,$existing,true))$db->exec('ALTER TABLE `'.$table.'` ADD COLUMN `'.$column.'` '.$definition);
}
// Mandatory audit writes must participate in financial transactions.
$db->exec('ALTER TABLE journal ENGINE=InnoDB');
$db->exec('ALTER TABLE journal MODIFY journal_entry TEXT NULL');
foreach(['club_dues','club_payments'] as $table){
$stmt=$db->prepare("SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME='recorded_by' AND REFERENCED_TABLE_NAME='users'");$stmt->execute([$table]);
if(!(int)$stmt->fetchColumn())$db->exec('ALTER TABLE `'.$table.'` ADD FOREIGN KEY (recorded_by) REFERENCES users(user_serial) ON DELETE RESTRICT');
}
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/003_production.sql')) as $sql)if(trim($sql))$db->exec($sql);
require __DIR__.'/../views/view_users.php';$db->exec(str_replace('create view','CREATE OR REPLACE VIEW',$SQL));
echo "Production safeguards schema is ready. Existing emails remain unverified until ownership is proven.\n";
+6
View File
@@ -0,0 +1,6 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/002_roster.sql')) as $sql){if(trim($sql))$db->exec($sql);}
echo "Club roster schema is ready. Existing accounts and administrative records were preserved.\n";
+11
View File
@@ -0,0 +1,11 @@
{
"jquery": {
"version": "3.7.1",
"file": "js/jquery-3.7.1.min.js",
"source": "https://code.jquery.com/jquery-3.7.1.min.js",
"sha256_base64": "/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=",
"upgrade_note": "Patched jQuery 3.x retained for compatibility with the existing Bootstrap plugins; browser regressions required for future upgrades."
},
"bootstrap": {"file": "js/bootstrap.bundle.min.js", "version": "5.3.8"},
"bootstrap-datepicker": {"file": "js/bootstrap-datepicker.min.js", "version": "1.9.0"}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
class AdminAccounts extends Base {
public function create(array $input): array {
$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))
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 (?,?)');
$stmt->execute([$name, $email, $email, $name]);
if ($stmt->fetch())
throw new AppException('An account already uses those details. Use the existing account or resend its invitation.', 409);
$db->beginTransaction();
try {
$stmt = $db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role,user_status,user_change_password,user_creator) VALUES(?,?,?,?,?,?,'active',1,?)");
$stmt->execute([$name, $first, $last, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $role, $_SESSION[self::USER_NAME] ?? '']);
$id = (int) $db->lastInsertId();
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'created account ' . json_encode(['user' => $id, 'name' => $name, 'role' => $role]));
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
$warning = '';
try {
if (!(new AuthService())->requestReset($email))
$warning = 'Account created, but the invitation is rate limited. Open the user summary to resend it later.';
} catch (Throwable $e) {
error_log('Invitation delivery failed: ' . $e->getMessage());
$warning = 'Account created, but its invitation could not be sent. Open the user summary to resend it after checking email settings.';
}
return ['ok' => true, 'user_serial' => $id, 'message' => 'Account created.', 'warning' => $warning];
}
}
+7 -19
View File
@@ -28,7 +28,7 @@ class Alerts extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
// ============
@@ -267,7 +267,7 @@ class Alerts extends Base {
// Find Criteria
$find_alert = filter_input(INPUT_POST, "find_alert") ?: "";
$find_words = $this->sanitizeString($find_alert);
$find_words = preg_quote(trim($find_alert));
// Show Per Page
@@ -295,28 +295,17 @@ class Alerts extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
$SQL = "select view_alerts.*
from view_alerts
where alert_title regexp :alert_title
limit {$this->pagination_size}
order by alert_serial
limit {$this->pagination_size}
offset {$offset}";
$statement = $connection->prepare($SQL);
@@ -334,7 +323,6 @@ class Alerts extends Base {
$alerts->addAttribute("end", $end);
$alerts->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $alerts;
}
+4
View File
@@ -0,0 +1,4 @@
<?php
class AppException extends RuntimeException {
public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); }
}
+23
View File
@@ -0,0 +1,23 @@
<?php
final class Audit {
public static function record(PDO $db, int $user, string $event): void {
try {
self::insert($db, $user, $event);
} catch (Throwable $e) {
error_log('Audit write failed: ' . $e->getMessage());
}
}
public static function required(PDO $db, int $user, string $event): void {
if (!$db->inTransaction())
throw new LogicException('Required audit writes need a transaction.');
self::insert($db, $user, $event);
}
public static function insert(PDO $db, int $user, string $event): void {
$stmt = $db->prepare('INSERT INTO journal(journal_origin,journal_ip,journal_entry) VALUES(?,?,?)');
$stmt->execute([(string) $user, $_SERVER['REMOTE_ADDR'] ?? '', $user . ' ' . $event]);
}
}
+22 -6
View File
@@ -61,6 +61,7 @@ class AuthService extends Base {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id');
$stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]);
Audit::record($this->connect(),(int)$user['user_serial'],'logged in ('.$user['user_role'].')');
return true;
}
@@ -92,7 +93,7 @@ class AuthService extends Base {
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100)
throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).');
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name))
throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.');
throw new InvalidArgumentException('Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.');
$error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? '');
if ($error)
throw new InvalidArgumentException($error);
@@ -113,14 +114,14 @@ class AuthService extends Base {
}
/** Never modify the password when somebody merely requests an email. */
public function requestReset(string $identifier): void {
public function requestReset(string $identifier): bool {
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
if (!$ipAllowed || !$accountAllowed)
return;
return false;
$user = $this->findUser($identifier);
if (!$user || $user['user_status'] !== 'active')
return;
return false;
$baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https'
|| parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null
@@ -129,15 +130,27 @@ class AuthService extends Base {
}
$token = bin2hex(random_bytes(32));
$db = $this->connect();
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
$hash = hash('sha256', $token);
$stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]);
$db->beginTransaction();
try {
// Share the user-row lock with account edits and password changes. A
// request resolved before such a change must not mint a token afterward.
$stmt=$db->prepare('SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user['user_serial']]);$current=$stmt->fetch();
if(!$current || $current['user_status']!=='active' || $current['user_email']!==$user['user_email'] || (int)$current['user_auth_version']!==(int)$user['user_auth_version']){
$db->rollBack();return false;
}
$stmt=$db->prepare('INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))');
$stmt->execute([$hash,$user['user_serial']]);$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token);
if (!$this->deliverResetEmail($user, $url)) {
$stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash');
$stmt->execute([':hash' => $hash]);
throw new RuntimeException('Password reset email delivery failed.');
}
Audit::record($db,(int)$user['user_serial'],'requested password reset');
return true;
}
protected function deliverResetEmail(array $user, string $url): bool {
@@ -174,7 +187,9 @@ class AuthService extends Base {
return false;
}
$this->storePassword($db, (int) $id, $password);
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');$stmt->execute([$id]);
$db->commit();
Audit::record($db,(int)$id,'reset password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
@@ -199,6 +214,7 @@ class AuthService extends Base {
}
$this->storePassword($db, $id, $password);
$db->commit();
Audit::record($db,$id,'changed password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
+39 -41
View File
@@ -13,7 +13,7 @@ class Base {
// Constants
// =========
const APPLICATION_NAME = "Hunting Club Roster";
const APPLICATION_NAME = "My Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.00";
const TIMEZONE = "America/New_York";
@@ -136,11 +136,16 @@ class Base {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, (int) ($settings->DatabasePort ?? 3306), $database);
return new PDO($dsn, $username, $password,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
$options=[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,]);
PDO::ATTR_PERSISTENT => false,];
$ca=getenv('HCR_DB_TLS_CA')?:'';
if((string)$settings->Environment==='Production'&&!in_array(strtolower($host),['localhost','127.0.0.1','::1'],true)&&!$ca)throw new RuntimeException('Remote production database connections require HCR_DB_TLS_CA and verified TLS.');
if($ca){if(!is_readable($ca))throw new RuntimeException('The database TLS CA file is not readable.');$options[PDO::MYSQL_ATTR_SSL_CA]=$ca;$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]=true;}
$db=new PDO($dsn,$username,$password,$options);
if($ca){$cipher=$db->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")->fetch();if(!$cipher||empty($cipher['Value']))throw new RuntimeException('The database connection did not negotiate TLS.');}
return $db;
} catch (PDOException $e) {
error_log("Database Connection Failed: " . $e->getMessage());
@@ -216,9 +221,7 @@ class Base {
// =====================
public function displayHome() {
$content = $this->applyXSL("", $this->getXSL("home"));
$this->displayContent($content);
(new Roster())->home();
}
// =======================
@@ -250,6 +253,7 @@ class Base {
}
}
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
if($action==='verifyemail'){(new EmailVerification())->page();return;}
if ($action === 'signout') {
if ($method !== 'POST') {
http_response_code(405);
@@ -275,9 +279,9 @@ class Base {
if (in_array($action, ['signin', 'signin.login', 'users.login'], true)) {
(new SignIn())->login();
}
$user = (new AuthService())->currentUser();
if (!$user) {
$ajax = $_SESSION[self::AJAX];
if (!empty($_SESSION[self::USER_AUTHENTICATED])) {
@@ -296,6 +300,7 @@ class Base {
(new Users())->changePassword();
return;
}
// Only named, reviewed entry points are routable. Helpers are never endpoints.
$routes = [
'' => [Base::class, 'displayHome', false],
@@ -306,24 +311,31 @@ class Base {
'validatedate' => [Base::class, 'validateDate', false],
'getage' => [Base::class, 'getAge', false],
'users.changepassword' => [Users::class, 'changePassword', false],
'print' => [Base::class, 'print', true],
'print_all_permitsbybuilder.print' => ['Print_All_PermitsByBuilder', 'print', true],
'export_allpermitsbybuildercsv.export' => ['Export_AllPermitsByBuilderCSV', 'export', true],
'export_allpermitsbybuilderexcel.export' => ['Export_AllPermitsByBuilderExcel', 'export', true],
'roster.workspace' => [Roster::class,'workspace',false],
'roster.help' => [Roster::class,'help',false],
'rosterreports.ledger' => [RosterReports::class,'ledger',true],
'rosterreports.csv' => [RosterReports::class,'csv',true],
'rosterreports.excel' => [RosterReports::class,'excel',true],
'rosterreports.pdf' => [RosterReports::class,'pdf',true],
];
$adminRoutes = [
'Users' => ['setupUsers', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
'Users' => ['setupUsers', 'userSummary', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
'Alerts' => ['setupAlerts', 'addAlert', 'editAlert', 'deleteAlert'],
'Dropdowns' => ['setupDropdowns', 'setupDropdowntypes', 'addDropdown', 'editDropdown', 'deleteDropdown', 'addDropdowntype', 'editDropdowntype', 'deleteDropdowntype', 'dropdownExists', 'dropdowntypeExists', 'dropdowntypetitleExists', 'dropdownActive', 'dropdowntypeActive', 'updateDropdownActive', 'updateDropdownDefault'],
'Popovers' => ['setupPopovers', 'addPopover', 'editPopover', 'deletePopover', 'popoverExists'],
'Reports' => ['setupReports', 'addReport', 'editReport', 'deleteReport', 'selectReport', 'reportExists'],
'Reports' => ['setupReports', 'selectReport'],
'Statuses' => ['setupStatuses', 'addStatus', 'editStatus', 'deleteStatus', 'statusExists', 'statusActive'],
'Journal' => ['viewJournal', 'clearJournal'],
'Notes' => ['addNote', 'editNote', 'deleteNote', 'addNoteQuick'],
'Journal' => ['viewJournal'],
'Notes' => ['addNote', 'editNote', 'deleteNote'],
];
foreach ($adminRoutes as $class => $methods) {
foreach ($methods as $entry)
foreach ($methods as $entry) {
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
}
}
if (!isset($routes[$action])) {
http_response_code(404);
@@ -334,11 +346,15 @@ class Base {
http_response_code(403);
exit('You do not have permission to perform this action.');
}
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword'], true)) {
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.help'], true)) {
http_response_code(405);
exit('Use POST for this action.');
}
(new $class())->$entry();
ob_start();
try{(new $class())->$entry();$body=ob_get_clean();}
catch(Throwable $e){ob_end_clean();throw $e;}
if($_SESSION[self::AJAX]??false){header('Content-Type: application/json');echo trim($body)===''?json_encode(['ok'=>true]):$body;}
else echo $body;
}
public function csrfToken(): string {
@@ -420,7 +436,7 @@ class Base {
public function getSettings() {
$path = getenv('HCR_SETTINGS_FILE') ?: (is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
$path = getenv('HCR_SETTINGS_FILE') ?: (getenv('HCR_ENVIRONMENT')!=='Production' && is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
$settings = simplexml_load_file($path, SimpleXMLElement::class, LIBXML_NONET);
if (!$settings)
throw new RuntimeException('Unable to load application settings.');
@@ -673,12 +689,8 @@ class Base {
// ============
public function logError($error_message = "") {
(new Journal())->journalEntry($error_message);
$this->displayHome();
exit();
error_log($error_message);
throw new AppException('The requested record is invalid or unavailable.',404);
}
// ==========================
@@ -725,20 +737,6 @@ class Base {
// Print a Report
// ==============
public function print() {
$_SESSION[self::REPORTS_GET] = $_GET;
$_SESSION[self::REPORTS_POST] = $_POST;
$report = filter_input(INPUT_POST, "report") ?: "";
if ($report !== 'Print_All_PermitsByBuilder') {
http_response_code(404);
exit('Report not found.');
}
(new $report())->print();
}
// =================
// Sanitize a string
// =================
+20 -125
View File
@@ -28,7 +28,7 @@ class Dropdowns extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
// ============
@@ -63,6 +63,7 @@ class Dropdowns extends Base {
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$SQL = "insert into dropdowns
@@ -117,6 +118,8 @@ class Dropdowns extends Base {
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
$SQL = "insert into dropdowntypes
@@ -158,19 +161,7 @@ class Dropdowns extends Base {
// ===============
public function deleteDropdown() {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown = $this->getDropdown($dropdown_serial);
if ($dropdown->count() == 0) {
$this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
}
$SQL = "delete from dropdowns
where dropdown_serial = {$dropdown_serial}";
$this->executeSQL($SQL);
(new MaintenanceService())->deleteDropdown(MaintenanceService::id($_POST['dropdown_serial']??null));
}
// ===================
@@ -178,19 +169,7 @@ class Dropdowns extends Base {
// ===================
public function deleteDropdowntype() {
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdowntype = $this->getDropdowntype($dropdowntype_serial);
if ($dropdowntype->count() == 0) {
$this->logError("Invalid Dropdowntype ({$dropdowntype_serial}). " . __METHOD__);
}
$SQL = "delete from dropdowntypes
where dropdowntype_serial = {$dropdowntype_serial}";
$this->executeSQL($SQL);
(new MaintenanceService())->deleteType(MaintenanceService::id($_POST['dropdowntype_serial']??null));
}
// ==============================
@@ -202,6 +181,7 @@ class Dropdowns extends Base {
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$dropdown_value = strtolower($dropdown_value);
@@ -291,48 +271,8 @@ class Dropdowns extends Base {
// =================================
public function dropdownActive() {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown = $this->getDropdown($dropdown_serial);
if ($dropdown->count() == 0) {
$this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
}
$table = (string) $dropdown->record->dropdowntype_table;
$column = (string) $dropdown->record->dropdowntype_column;
$active = false;
switch ($table) {
case "*none": // No table, never Active.
break;
case "*function": // Requires it's own function to determine if Active.
if (method_exists(__CLASS__, $column)) {
$active = $this->$column($dropdown_serial);
}
break;
default: // All others, test the Column in the Table.
$SQL = "select {$column}
from {$table}
where {$column} = {$dropdown_serial}";
$results = $this->getTable("results", $SQL);
$active = ($results->count() > 0) ? true : false;
break;
}
echo json_encode($active);
$id=MaintenanceService::id($_POST['dropdown_serial']??null);
echo json_encode((new MaintenanceService())->dropdownUsed($id));
}
// =====================================
@@ -386,6 +326,7 @@ class Dropdowns extends Base {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$dropdown = $this->getDropdown($dropdown_serial);
@@ -451,6 +392,8 @@ class Dropdowns extends Base {
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
$dropdowntype = $this->getDropdowntype($dropdowntype_serial);
@@ -559,11 +502,11 @@ class Dropdowns extends Base {
// Find Criteria
$find_dropdowntype = filter_input(INPUT_POST, "find_dropdowntype") ?: "";
$find_words = $this->sanitizeString($find_dropdowntype);
$find_words = preg_quote(trim($find_dropdowntype));
// Show Per Page
$this->pagination_size = filter_input(INPUT_POST, "users_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
$this->pagination_size = filter_input(INPUT_POST, "dropdowntypes_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
// Record Count
@@ -593,21 +536,9 @@ class Dropdowns extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -617,6 +548,7 @@ class Dropdowns extends Base {
or dropdowntype_title regexp :dropdowntype_title
or dropdowntype_table regexp :dropdowntype_table
or dropdowntype_column regexp :dropdowntype_column)
order by dropdowntype_name,dropdowntype_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -638,7 +570,6 @@ class Dropdowns extends Base {
$dropdowntypes->addAttribute("end", $end);
$dropdowntypes->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $dropdowntypes;
}
@@ -682,24 +613,7 @@ class Dropdowns extends Base {
// ======================
public function updateDropdownActive() {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_checked = filter_input(INPUT_POST, "dropdown_checked", FILTER_VALIDATE_BOOLEAN) ?: false;
$SQL = "update dropdowns
set dropdown_active = :dropdown_active,
dropdown_changer = :dropdown_changer,
dropdown_changed = :dropdown_changed
where dropdown_serial = :dropdown_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":dropdown_serial", $dropdown_serial);
$statement->bindValue(":dropdown_active", $dropdown_checked, PDO::PARAM_BOOL);
$statement->bindValue(":dropdown_changer", $this->current_user_name);
$statement->bindValue(":dropdown_changed", $this->getTimestamp());
$statement->execute();
(new MaintenanceService())->setDropdownActive(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
}
// =======================
@@ -707,26 +621,7 @@ class Dropdowns extends Base {
// =======================
public function updateDropdownDefault() {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_dropdowntype = filter_input(INPUT_POST, "dropdown_dropdowntype", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_checked = filter_input(INPUT_POST, "dropdown_checked") ?: "false";
// Unset the Default for the Dropdown Type.
$SQL = "update dropdowns
set dropdown_default = false
where dropdown_dropdowntype = {$dropdown_dropdowntype}";
$this->executeSQL($SQL);
// Set the Default for the clicked Dropdown.
$SQL = "update dropdowns
set dropdown_default = {$dropdown_checked}
where dropdown_serial = {$dropdown_serial}";
$this->executeSQL($SQL);
(new MaintenanceService())->setDropdownDefault(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::id($_POST['dropdown_dropdowntype']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
class EmailVerification extends Base {
public function request(string $identifier): bool {
$auth = new AuthService();
if (!$auth->allowAttempt('verify-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600) || !$auth->allowAttempt('verify-account', $identifier, 3, 3600))
return false;
$user = $auth->findUser($identifier);
if (!$user || $user['user_status'] !== 'active' || $user['user_email_verified_at'])
return false;
$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 application URL.');
$token = bin2hex(random_bytes(32));
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user['user_serial']]);
$current = $stmt->fetch();
if (!$current || $current['user_status'] !== 'active' || $current['user_email_verified_at'] || $current['user_email'] !== $user['user_email'] || (int) $current['user_auth_version'] !== (int) $user['user_auth_version']) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('INSERT INTO email_verifications(token_hash,user_serial,email,auth_version,expires_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 24 HOUR))');
$stmt->execute([hash('sha256', $token), $user['user_serial'], $user['user_email'], $user['user_auth_version']]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
if (!$this->deliver($user, $url . '/main.php?Xaction=verifyemail&token=' . $token)) {
$stmt = $db->prepare('DELETE FROM email_verifications WHERE token_hash=?');
$stmt->execute([hash('sha256', $token)]);
throw new RuntimeException('Verification email could not be delivered.');
}
return true;
}
protected function deliver(array $user, string $url): bool {
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
return (new Email())->sendEmail($user['user_email'], self::APPLICATION_NAME . ' - Verify your email', '<p>Verify the email address for account ' . $h($user['user_name']) . '.</p><p><a href="' . $h($url) . '">Verify email</a></p><p>The link expires in 24 hours. You must also enter the account password. If you did not create this account, ignore this message or use password reset to secure it.</p>');
}
public function confirm(string $token, string $password): bool {
if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
return false;
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT user_serial FROM email_verifications WHERE token_hash=?');
$stmt->execute([hash('sha256', $token)]);
$id = $stmt->fetchColumn();
if (!$id) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]);
$user = $stmt->fetch();
$stmt = $db->prepare('SELECT * FROM email_verifications WHERE token_hash=? AND expires_at>UTC_TIMESTAMP() FOR UPDATE');
$stmt->execute([hash('sha256', $token)]);
$row = $stmt->fetch();
if (!$row || !$user || $user['user_status'] !== 'active' || $row['email'] !== $user['user_email'] || (int) $row['auth_version'] !== (int) $user['user_auth_version'] || !password_verify($password, $user['user_password_hash'])) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');
$stmt->execute([$id]);
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
$stmt->execute([$id]);
Audit::required($db, (int) $id, 'verified email ownership');
$db->commit();
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function page(): void {
$token = $_POST['token'] ?? $_GET['token'] ?? '';
$message = '';
$complete = false;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
if (($_POST['step'] ?? '') === 'confirm') {
$allowed = (new AuthService())->allowAttempt('verify-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900);
$complete = $allowed && $this->confirm($token, $_POST['user_password'] ?? '');
$message = $complete ? 'Email verified. You can now be assigned to a club.' : 'Unable to verify. Check the password or request a fresh link. You can also reset your password to prove mailbox ownership.';
} else {
try {
$this->request(trim($_POST['user_email'] ?? ''));
} catch (Throwable $e) {
error_log('Email verification failed: ' . $e->getMessage());
}
$message = 'If an active account needs verification, a link will be sent. You can also reset your password to verify its email.';
}
}
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
echo '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Verify email</title><link rel="stylesheet" href="css/themes/MyHuntingClubRoster/bootstrap.css"></head><body><main class="container py-4"><h1>Verify your email</h1><p>' . $h($message) . '</p>';
if (!$complete) {
echo '<form method="post" action="main.php"><input type="hidden" name="action" value="verifyemail"><input type="hidden" name="csrf_token" value="' . $h($this->csrfToken()) . '">';
if ($token)
echo '<input type="hidden" name="step" value="confirm"><input type="hidden" name="token" value="' . $h($token) . '"><label class="form-label">Account password <input class="form-control" type="password" name="user_password" autocomplete="current-password" required></label><button class="btn btn-primary">Verify email</button>';
else
echo '<label class="form-label">Email <input class="form-control" type="email" name="user_email" required></label><button class="btn btn-primary">Send verification link</button>';
echo '</form>';
}
echo '<p><a href="main.php">Return to application</a> · <a href="main.php?Xaction=verifyemail">Request another link</a> · <a href="main.php?Xaction=resetpassword">Reset password</a></p></main></body></html>';
exit;
}
}
+10 -23
View File
@@ -27,7 +27,7 @@ class Journal extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
@@ -37,12 +37,8 @@ class Journal extends Base {
public function clearJournal() {
$SQL = "truncate journal";
$this->executeSQL($SQL);
$this->journalEntry("Journal was Cleared.");
throw new AppException('Audit history cannot be cleared through the application.',403);
}
// =======================
@@ -70,7 +66,7 @@ class Journal extends Base {
// Find Criteria
$find_journal = filter_input(INPUT_POST, "find_journal") ?: "";
$find_words = $this->sanitizeString($find_journal);
$find_words = preg_quote(trim($find_journal));
// Show Per Page
@@ -103,19 +99,10 @@ class Journal extends Base {
// Pagination
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1; break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1); break;
case "next" : $this->pagination_page = ($this->pagination_page + 1); break;
case "last" : $this->pagination_page = ($count / $this->pagination_size); break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -125,6 +112,7 @@ class Journal extends Base {
or journal_origin regexp :journal_origin
or journal_ip regexp :journal_ip
or journal_entry regexp :journal_entry
order by journal_timestamp DESC,journal_serial DESC
limit {$this->pagination_size}
offset {$offset}";
@@ -145,8 +133,7 @@ class Journal extends Base {
$journal->addAttribute("start", $start);
$journal->addAttribute("end", $end);
$journal->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $journal;
+68
View File
@@ -0,0 +1,68 @@
<?php
/** Append-only financial records. Every write and its audit event commit together. */
class LedgerService extends RosterService {
private function actor(): int {return (int)($_SESSION[self::USER_SERIAL]??0);}
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;}
}
private function lockedDue(PDO $db,int $club,int $due): array {
$this->club($club,$db,true);
$stmt=$db->prepare('SELECT * FROM club_dues WHERE due_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$due,$club]);$row=$stmt->fetch();
if(!$row)throw new AppException('Dues record not found in this club.',404);
$stmt=$db->prepare('SELECT COALESCE(SUM(amount_cents),0) FROM club_due_adjustments WHERE due_serial=?');$stmt->execute([$due]);$row['effective']=(int)$row['amount_cents']+(int)$stmt->fetchColumn();
$stmt=$db->prepare('SELECT COALESCE(SUM(p.amount_cents),0) FROM club_payments p WHERE p.due_serial=? AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)');$stmt->execute([$due]);$row['paid']=(int)$stmt->fetchColumn();return $row;
}
public function charge(int $club,int $membership,int $season,string $amount): void {
$cents=self::cents($amount);
$this->transaction(function(PDO $db)use($club,$membership,$season,$cents){
$this->club($club,$db,true);
$stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE membership_serial=? AND club_serial=? AND membership_status='active' FOR UPDATE");$stmt->execute([$membership,$club]);if(!$stmt->fetch())throw new AppException('Select an active member of this club.',409);
$stmt=$db->prepare('SELECT season_serial FROM club_seasons WHERE season_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$season,$club]);if(!$stmt->fetch())throw new AppException('Select a season belonging to this club.',409);
$stmt=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,?)');$stmt->execute([$club,$membership,$season,$cents,$this->actor()]);
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 {
$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){
$row=$this->lockedDue($db,$club,$due);
$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);
$stmt=$db->prepare('INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,recorded_by,payment_method,payment_reference) VALUES(?,?,?,?,?,?,?)');$stmt->execute([$due,$cents,$paid,$key,$this->actor(),$method,$reference]);
Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference]));
});
}
public function adjust(int $club,int $due,string $total,string $reason,string $key): void {
$cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){
$row=$this->lockedDue($db,$club,$due);$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;}
if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409);
if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409);
$stmt=$db->prepare('INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,recorded_by,request_key) VALUES(?,?,?,?,?,?)');$stmt->execute([$due,$cents-$row['effective'],$cents,$reason,$this->actor(),$key]);
Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason]));
});
}
public function reverse(int $club,int $payment,string $reason,string $key): void {
$reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key){
$this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404);
$this->lockedDue($db,$club,(int)$row['due_serial']);
$stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch();
if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);}
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]);
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 {
$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;
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
/** Transactional rules shared by the administrative controllers. */
class MaintenanceService extends Base {
public static function id(mixed $value): int {
$id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id === false)
throw new AppException('A valid record ID is required.');
return $id;
}
public static function boolean(mixed $value): bool {
if (!is_scalar($value))
throw new AppException('A true or false value is required.');
$result = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if (!is_scalar($value) || $result === null || $value === '')
throw new AppException('A true or false value is required.');
return $result;
}
public static function usageRule(string $table, string $column): void {
// No current roster field uses dropdown IDs. Add explicit code-owned references
// here when such a feature is implemented; database values are never SQL/callbacks.
if ($table !== '*none' || $column !== '') {
throw new AppException('Only "No usage reference" is supported. Custom table names and callbacks are disabled.');
}
}
public function dropdownUsed(int $id, ?PDO $db = null): bool {
$stmt = ($db ?? $this->connect())->prepare('SELECT d.dropdown_default,t.dropdowntype_table, t.dropdowntype_column FROM dropdowns d JOIN dropdowntypes t ON t.dropdowntype_serial=d.dropdown_dropdowntype WHERE d.dropdown_serial=?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row)
throw new AppException('Dropdown not found.', 404);
self::usageRule((string) $row['dropdowntype_table'], (string) $row['dropdowntype_column']);
return (bool) $row['dropdown_default'];
}
public function deleteDropdown(int $id): void {
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT dropdown_dropdowntype FROM dropdowns WHERE dropdown_serial=?');
$stmt->execute([$id]);
$type = $stmt->fetchColumn();
if (!$type)
throw new AppException('Dropdown not found.', 404);
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
$stmt->execute([$type]);
$stmt->fetch();
$stmt = $db->prepare('SELECT dropdown_default FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row)
throw new AppException('Dropdown not found.', 404);
if ($row['dropdown_default'] || $this->dropdownUsed($id, $db))
throw new AppException('Choose another default before deleting this dropdown.', 409);
$stmt = $db->prepare('DELETE FROM dropdowns WHERE dropdown_serial=?');
$stmt->execute([$id]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function deleteType(int $id): void {
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
$stmt->execute([$id]);
if (!$stmt->fetch())
throw new AppException('Dropdown type not found.', 404);
$stmt = $db->prepare('SELECT dropdown_serial FROM dropdowns WHERE dropdown_dropdowntype=? LIMIT 1');
$stmt->execute([$id]);
if ($stmt->fetch())
throw new AppException('Remove the dropdown values before deleting their type.', 409);
$stmt = $db->prepare('DELETE FROM dropdowntypes WHERE dropdowntype_serial=?');
$stmt->execute([$id]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function setDropdownDefault(int $id, int $type, bool $checked): void {
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
$stmt->execute([$type]);
if (!$stmt->fetch())
throw new AppException('Dropdown type not found.', 404);
$stmt = $db->prepare('SELECT dropdown_dropdowntype,dropdown_active FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row || (int) $row['dropdown_dropdowntype'] !== $type)
throw new AppException('Dropdown does not belong to this type.', 409);
if ($checked && !$row['dropdown_active'])
throw new AppException('Activate the dropdown before selecting it as the default.', 409);
if ($checked) {
$stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=0 WHERE dropdown_dropdowntype=?');
$stmt->execute([$type]);
}
$stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=?, dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
$stmt->execute([(int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function setDropdownActive(int $id, bool $checked): void {
$stmt = $this->connect()->prepare('UPDATE dropdowns SET dropdown_active=?,dropdown_default=IF(?=0,0,dropdown_default),dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
$stmt->execute([(int) $checked, (int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
}
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);
}
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);
}
}
public function userUsed(int $id, ?PDO $db = null): bool {
$db ??= $this->connect();
$stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?");
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0)
return true;
$stmt = $db->prepare('SELECT COUNT(*) FROM journal WHERE journal_origin=?');
$stmt->execute([(string) $id]);
if ((int) $stmt->fetchColumn() > 0)
return true;
// Roster tables are installed by the separate non-destructive roster migration.
$exists = $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='club_memberships'")->fetchColumn();
if ($exists) {
$stmt = $db->prepare('SELECT COUNT(*) FROM club_memberships WHERE user_serial=?');
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0)
return true;
}
return false;
}
public function deleteUser(int $id): void {
$db = $this->connect();
$db->beginTransaction();
try {
$admins = $this->lockAdministrators($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')
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))
throw new AppException('This user has recorded history. Disable the account to preserve its history.', 409);
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
$stmt->execute([$id]);
$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 {
$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))
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')
throw new AppException('Only a system administrator can assign that role.', 403);
$db = $this->connect();
$db->beginTransaction();
try {
$admins = $this->lockAdministrators($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')
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;
$stmt = $db->prepare('UPDATE users SET user_name=?,user_email=?,user_first_name=?,user_last_name=?,user_role=?,user_status=?,user_auth_version=user_auth_version+?,user_changer=?,user_changed=? WHERE user_serial=?');
$stmt->execute([$name, $email, $first, $last, $role, $status, (int) $revoke, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
if ($old['user_email'] !== $email) {
$stmt = $db->prepare('UPDATE users SET user_email_verified_at=NULL WHERE user_serial=?');
$stmt->execute([$id]);
}
if ($revoke) {
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
$stmt->execute([$id]);
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
$stmt->execute([$id]);
}
$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 {
$db = $this->connect();
$db->beginTransaction();
try {
$admins = $this->lockAdministrators($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')
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=?');
$stmt->execute([$active ? 'active' : 'inactive', $id]);
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
$stmt->execute([$id]);
$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 {
$db = $this->connect();
$db->beginTransaction();
try {
$rows = $db->query('SELECT status_serial,status_default FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll(PDO::FETCH_KEY_PAIR);
if (!array_key_exists($id, $rows))
throw new AppException('Status not found.', 404);
if ($rows[$id])
throw new AppException('Choose another default before deleting this status.', 409);
// Current club membership statuses are explicit enums, not references to this list.
$stmt = $db->prepare('DELETE FROM statuses WHERE status_serial=?');
$stmt->execute([$id]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function saveStatus(int $id, array $input): void {
$name = trim($input['status_name'] ?? '');
$classes = trim($input['status_classes'] ?? '');
$active = self::boolean($input['status_active'] ?? false);
$default = self::boolean($input['status_default'] ?? false);
if (!$name || strlen($name) > 100 || strlen($classes) > 100 || !preg_match('/\A[A-Za-z0-9_ -]*\z/', $classes))
throw new AppException('Enter a status name and valid CSS classes (up to 100 bytes).');
if ($default && !$active)
throw new AppException('The default status must be active.');
$db = $this->connect();
$db->beginTransaction();
try {
$db->query('SELECT status_serial FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll();
$stmt = $db->prepare('SELECT status_serial FROM statuses WHERE LOWER(status_name)=LOWER(?) AND status_serial<>?');
$stmt->execute([$name, $id]);
if ($stmt->fetch())
throw new AppException('A status with that name already exists.', 409);
if ($default)
$db->exec('UPDATE statuses SET status_default=0');
if ($id) {
$stmt = $db->prepare('SELECT status_serial FROM statuses WHERE status_serial=?');
$stmt->execute([$id]);
if (!$stmt->fetch())
throw new AppException('Status not found.', 404);
$stmt = $db->prepare('UPDATE statuses SET status_name=?,status_classes=?,status_active=?,status_default=?,status_changer=?,status_changed=? WHERE status_serial=?');
$stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
} else {
$stmt = $db->prepare('INSERT INTO statuses(status_name,status_classes,status_active,status_default,status_creator,status_created) VALUES(?,?,?,?,?,?)');
$stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp()]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
}
+2 -93
View File
@@ -1,96 +1,5 @@
<?php
// =============
// Metrics Class
// =============
/** Roster overview replaces the unused work-order metrics module. */
class Metrics extends Base {
// ==================
// Class Constructor.
// ==================
public function __construct() {
set_error_handler(array($this,"displayError"));
date_default_timezone_set(self::TIMEZONE);
}
// ========================================
// Return a Daily Work Order Metrics Object
// ========================================
private function getDailyWorkOrderMetrics() {
$SQL = "select dayname(workorder_created) as day,
count(*) as count
from view_workorders
where date(workorder_created) >= (date(now()) - interval 6 day)
group by day
order by workorder_created desc";
return $this->getTable("dailymetrics", $SQL);
}
// =====================================
// Return a Monthly Call Metrics Object.
// =====================================
private function getMonthlyWorkOrderMetrics() {
$SQL = "select monthname(workorder_created) as month,
count(*) as count
from view_workorders
where date(workorder_created) >= (date(now()) - interval 6 month)
group by month
order by workorder_created desc";
return $this->getTable("monthlymetrics", $SQL);
}
// =========================================
// Return a Weekly Work Order Metrics Object
// =========================================
private function getWeeklyWorkOrderMetrics() {
$SQL = "select week(workorder_created) as week,
count(*) as count
from view_workorders
where date(workorder_created) >= (date(now()) - interval 6 week)
group by week
order by workorder_created desc";
return $this->getTable("weeklymetrics", $SQL);
}
// =======================
// View Work Order Metrics
// =======================
public function viewWorkOrderMetrics() {
$dailymetrics = $this->getDailyWorkOrderMetrics();
$weeklymetrics = $this->getWeeklyWorkOrderMetrics();
$monthlymetrics = $this->getMonthlyWorkorderMetrics();
$XSLParms["DAILYMETRICS"] = $dailymetrics->asXML();
$XSLParms["WEEKLYMETRICS"] = $weeklymetrics->asXML();
$XSLParms["MONTHLYMETRICS"] = $monthlymetrics->asXML();
$content = $this->applyXSL("", $this->getXSL("viewWorkOrderMetrics"), $XSLParms);
$this->displayContent($content);
exit();
}
public function viewRosterMetrics(){(new Roster())->home();}
}
?>
+28 -237
View File
@@ -1,244 +1,35 @@
<?php
// ===========
// Notes Class
// ===========
class Notes extends Base {
// Constants
const VALID_TYPES = array("User");
// Variables
private $current_user_name = "";
private $current_user_email = "";
// =================
// Class Constructor
// =================
public function __construct() {
set_error_handler(array($this, "displayError"));
date_default_timezone_set(self::TIMEZONE);
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
public const VALID_TYPES=['User'];
public function addNote(){
if(($_POST['step']??'prompt')==='add'){$this->write(0,$_POST['note_type']??'',MaintenanceService::id($_POST['note_reference']??null),$_POST['note_text']??'');return;}
$type=$_POST['note_type']??'';$id=MaintenanceService::id($_POST['note_reference']??null);
if(!in_array($type,self::VALID_TYPES,true))throw new AppException('Unsupported note type.');
$stmt=$this->connect()->prepare('SELECT user_serial FROM users WHERE user_serial=?');$stmt->execute([$id]);if(!$stmt->fetch())throw new AppException('User not found.',404);
$this->displayContent($this->applyXSL('<XML/>',$this->getXSL('addNote'),['NOTE_TYPE'=>$type,'NOTE_REFERENCE'=>$id]));
}
// ========
// Add Note
// ========
public function addNote() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$note_type = filter_input(INPUT_POST, "note_type") ?: "";
$note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
if (!in_array($note_type, self::VALID_TYPES) or empty($note_reference)) {
$this->logError("Invalid Note Type ({$note_type} or Reference {$note_reference} " . __METHOD__);
}
$XSLParms["NOTE_TYPE"] = $note_type;
$XSLParms["NOTE_REFERENCE"] = $note_reference;
$content = $this->applyXSL("", $this->getXSL("addNote"), $XSLParms);
$this->displayContent($content);
break;
case "add":
$note_type = filter_input(INPUT_POST, "note_type") ?: "";
$note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$note_text = filter_input(INPUT_POST, "note_text") ?: "";
$SQL = "insert into notes
( note_type,
note_reference,
note_text,
note_origin )
VALUES ( :note_type,
:note_reference,
:note_text,
:note_origin )";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":note_type", $note_type);
$statement->bindValue(":note_reference", $note_reference);
$statement->bindValue(":note_text", $note_text);
$statement->bindValue(":note_origin", $this->current_user_name);
$statement->execute();
break;
default:
$this->displayHome();
}
public function editNote(){
$id=MaintenanceService::id($_POST['note_serial']??null);$note=$this->getNote($id);
if(!$note->count())throw new AppException('Note not found.',404);
if(($_POST['step']??'prompt')==='update'){$this->write($id,(string)$note->record->note_type,(int)$note->record->note_reference,$_POST['note_text']??'');return;}
$this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
}
// ===========
// Delete Note
// ===========
public function deleteNote() {
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$note = $this->getNote($note_serial);
if ($note->count() == 0) {
$this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
}
$SQL = "delete from notes
where note_serial = {$note_serial}";
$this->executeSQL($SQL);
public function deleteNote(){
$stmt=$this->connect()->prepare('DELETE FROM notes WHERE note_serial=?');$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
}
// =========
// Edit Note
// =========
public function editNote() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$note = $this->getNote($note_serial);
if ($note->count() == 0) {
$this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
}
$XML = $this->mergeXML($note, "<XML/>");
$content = $this->applyXSL($XML, $this->getXSL("editNote"));
$this->displayContent($content);
break;
case "update":
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$note_text = filter_input(INPUT_POST, "note_text") ?: "";
$SQL = "update notes
set note_text = :note_text,
note_origin = :note_origin
where note_serial = :note_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":note_serial", $note_serial);
$statement->bindValue(":note_text", $note_text);
$statement->bindValue(":note_origin", $this->current_user_name);
$statement->execute();
break;
default:
$this->displayHome();
}
}
// ====================
// Return a Note Object
// ====================
public function getNote($note_serial = 0) {
$SQL = "select view_notes.*
from view_notes
where note_serial = {$note_serial}";
return $this->getTable("notes", $SQL);
}
// =====================
// Return a Notes Object
// =====================
public function getNotes($note_type = "", $note_reference = 0) {
$SQL = "select view_notes.*
from view_notes
where note_type = '{$note_type}'
and note_reference = {$note_reference}
order by note_timestamp asc";
return $this->getTable("notes", $SQL);
}
// =====================
// Return a Notes Object
// =====================
public function getAllNotes($note_reference = 0) {
$SQL = "select view_notes.*
from view_notes
where note_reference = {$note_reference}
order by note_timestamp asc";
// $this->debug($SQL);
return $this->getTable("notes", $SQL);
}
// ==============
// Add Note Quick
// ==============
public function addNoteQuick($note_type = "", $note_reference = 0, $note_text = "") {
$SQL = "insert into notes
( note_type,
note_reference,
note_text,
note_origin )
VALUES ( :note_type,
:note_reference,
:note_text,
:note_origin )";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":note_type", $note_type);
$statement->bindValue(":note_reference", $note_reference);
$statement->bindValue(":note_text", $note_text);
$statement->bindValue(":note_origin", $this->current_user_name);
$statement->execute();
public function write(int $id,string $type,int $reference,string $text): int {
$text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).');
$db=$this->connect();$db->beginTransaction();
try{
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$reference]);if(!$stmt->fetch())throw new AppException('The note must refer to an existing user.',409);
if($id){$stmt=$db->prepare('SELECT note_serial FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,$type,$reference]);if(!$stmt->fetch())throw new AppException('Note not found.',404);
$stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id]);
}else{$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([$type,$reference,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();}
$db->commit();return $id;
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
}
public function getNote($id=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_serial=?');$stmt->execute([(int)$id]);return $this->getTableXML('notes',$stmt);}
public function getNotes($type='',$reference=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC');$stmt->execute([$type,(int)$reference]);return $this->getTableXML('notes',$stmt);}
public function getAllNotes($reference=0){return $this->getNotes('User',$reference);}
public function addNoteQuick($type='',$reference=0,$text=''){return $this->write(0,$type,(int)$reference,$text);}
}
?>
+14
View File
@@ -0,0 +1,14 @@
<?php
final class Pagination {
public static function calculate(int $count,int $size,int $current,string $action=''): array {
$count=max(0,$count);$size=max(1,min(100,$size));$last=max(1,(int)ceil($count/$size));
$page=match($action){'first'=>1,'previous'=>$current-1,'next'=>$current+1,'last'=>$last,default=>$current};
$page=max(1,min($last,$page));$offset=($page-1)*$size;
return ['size'=>$size,'page'=>$page,'offset'=>$offset,'start'=>$count?$offset+1:0,'end'=>min($count,$offset+$size)];
}
public static function session(string $scope,int $count,int $size,string $action=''): array {
if(!is_array($_SESSION[Base::PAGINATION_PAGE]??null))$_SESSION[Base::PAGINATION_PAGE]=[];
$result=self::calculate($count,$size,(int)($_SESSION[Base::PAGINATION_PAGE][$scope]??1),$action);
$_SESSION[Base::PAGINATION_PAGE][$scope]=$result['page'];return $result;
}
}
+6 -18
View File
@@ -28,7 +28,7 @@ class Popovers extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
// ===========
@@ -229,7 +229,7 @@ class Popovers extends Base {
// Find Criteria
$find_popover = filter_input(INPUT_POST, "find_popover") ?: "";
$find_words = $this->sanitizeString($find_popover);
$find_words = preg_quote(trim($find_popover));
// Show Per Page
@@ -259,21 +259,9 @@ class Popovers extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -281,6 +269,7 @@ class Popovers extends Base {
from view_popovers
where popover_name regexp :popover_name
or popover_title regexp :popover_title
order by popover_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -300,7 +289,6 @@ class Popovers extends Base {
$popovers->addAttribute("end", $end);
$popovers->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $popovers;
}
+2 -476
View File
@@ -1,479 +1,5 @@
<?php
// =============
// Reports Class
// =============
class Reports extends Base {
// Variables.
private $current_user_name = "";
private $current_user_email = "";
private $pagination_size = 20;
private $pagination_page = 1;
// =================
// Class Constructor
// =================
public function __construct() {
set_error_handler(array($this, "displayError"));
date_default_timezone_set(self::TIMEZONE);
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
}
// ==========
// Add Report
// ==========
public function addReport() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$constants = $this->getConstants();
$XML = $this->mergeXML($constants, "<XML/>");
$content = $this->applyXSL($XML, $this->getXSL("addReport"));
$this->displayContent($content);
break;
case "add":
$report_name = filter_input(INPUT_POST, "report_name") ?: "";
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
$report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
$report_description = filter_input(INPUT_POST, "report_description") ?: "";
$report_target = filter_input(INPUT_POST, "report_target") ?: "";
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
$SQL = "insert into reports
( report_class,
report_type,
report_name,
report_description,
report_target )
VALUES ( :report_class,
:report_type,
:report_name,
:report_description,
:report_target)";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":report_class", $report_class);
$statement->bindValue(":report_type", $report_type);
$statement->bindValue(":report_name", $report_name);
$statement->bindValue(":report_description", $report_description);
$statement->bindValue(":report_target", $report_target);
$statement->execute();
break;
default:
$this->displayHome();
}
}
// =============
// Delete Report
// =============
public function deleteReport() {
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$reports = $this->getReport($report_serial);
if ($reports->count() == 0) {
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
}
$SQL = "delete from reports
where report_serial = {$report_serial}";
$this->executeSQL($SQL);
}
// ===========
// Edit Report
// ===========
public function editReport() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$reports = $this->getReport($report_serial);
if ($reports->count() == 0) {
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
}
$constants = $this->getConstants();
$XML = $this->mergeXML($reports, "<XML/>");
$XML = $this->mergeXML($constants, $XML);
$content = $this->applyXSL($XML, $this->getXSL("editReport"));
$this->displayContent($content);
break;
case "update":
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$report_name = filter_input(INPUT_POST, "report_name") ?: "";
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
$report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
$report_description = filter_input(INPUT_POST, "report_description") ?: "";
$report_target = filter_input(INPUT_POST, "report_target") ?: "";
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
$reports = $this->getReport($report_serial);
if ($reports->count() == 0) {
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
}
$SQL = "update reports
set report_class = :report_class,
report_type = :report_type,
report_name = :report_name,
report_description = :report_description,
report_target = :report_target
where report_serial = :report_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":report_serial", $report_serial);
$statement->bindValue(":report_class", $report_class);
$statement->bindValue(":report_type", $report_type);
$statement->bindValue(":report_name", $report_name);
$statement->bindValue(":report_description", $report_description);
$statement->bindValue(":report_target", $report_target);
$statement->execute();
break;
default:
$this->displayHome();
}
}
// ======================
// Return a Report Object
// ======================
public function getReport($report_serial = 0) {
$SQL = "select view_reports.*
from view_reports
where report_serial = {$report_serial}";
return $this->getTable("reports", $SQL);
}
// ===========================================
// Return the report_serial for a report_class
// ===========================================
public function getReportSerial($report_class = "") {
$SQL = "select view_reports.report_serial
from view_reports
where report_class = '{$report_class}'";
$reports = $this->getTable("reports", $SQL);
return (int) ($reports->record->report_serial ?? 0);
}
// =======================
// Return a Reports Object
// =======================
public function getReports() {
$SQL = "select view_reports.*
from view_reports";
return $this->getTable("reports", $SQL);
}
// ============================
// Return a Reports Page Object
// ============================
public function getReportsPage() {
// Find Criteria
$find_report = filter_input(INPUT_POST, "find_report") ?: "";
$find_words = $this->sanitizeString($find_report);
// $find_words = implode("|", explode(" ", $find_report));
// Show Per Page
$this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
// Record Count
$connection = $this->connect();
$SQL = "select count(*) as count
from view_reports
where (report_class regexp :report_class
or report_name regexp :report_name
or report_type regexp :report_type
or report_description regexp :report_description)";
$statement = $connection->prepare($SQL);
$statement->bindValue(":report_class", $find_words);
$statement->bindValue(":report_name", $find_words);
$statement->bindValue(":report_type", $find_words);
$statement->bindValue(":report_description", $find_words);
$statement->execute();
$recordset = $statement->fetch(PDO::FETCH_ASSOC);
$count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
// Pagination
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
// Data
$SQL = "select view_reports.*
from view_reports
where (report_class regexp :report_class
or report_name regexp :report_name
or report_type regexp :report_type
or report_description regexp :report_description)
limit {$this->pagination_size}
offset {$offset}";
$statement = $connection->prepare($SQL);
$statement->bindValue(":report_class", $find_words);
$statement->bindValue(":report_name", $find_words);
$statement->bindValue(":report_type", $find_words);
$statement->bindValue(":report_description", $find_words);
$statement->execute();
$reports = $this->getTableXML("reports", $statement);
// Insert Pagination Attributes
$reports->addAttribute("count", $count);
$reports->addAttribute("start", $start);
$reports->addAttribute("end", $end);
$reports->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $reports;
}
// ==================================
// Return a Select Report Page Object
// ==================================
public function getSelectReportPage() {
// Find Criteria
$select_report = filter_input(INPUT_POST, "select_report") ?: "";
$find_words = $this->sanitizeString($select_report);
// Show Per Page
$this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
// Record Count
$connection = $this->connect();
$SQL = "select count(*) as count
from view_reports
where report_name regexp :report_name
or report_description regexp :report_description";
$statement = $connection->prepare($SQL);
$statement->bindValue(":report_name", $find_words);
$statement->bindValue(":report_description", $find_words);
$statement->execute();
$recordset = $statement->fetch(PDO::FETCH_ASSOC);
$count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
// Pagination
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
// Data
$SQL = "select view_reports.*
from view_reports
where report_name regexp :report_name
or report_description regexp :report_description
limit {$this->pagination_size}
offset {$offset}";
$statement = $connection->prepare($SQL);
$statement->bindValue(":report_name", $find_words);
$statement->bindValue(":report_description", $find_words);
$statement->execute();
$reports = $this->getTableXML("reports", $statement);
// Insert Pagination Attributes
$reports->addAttribute("count", $count);
$reports->addAttribute("start", $start);
$reports->addAttribute("end", $end);
$reports->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $reports;
}
// ====================================
// Determine if the Report Class Exists
// ====================================
public function reportExists() {
$exists = false;
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
$SQL = "select report_serial
from view_reports
where report_serial != :report_serial
and lower(report_class) = :report_class";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":report_serial", $report_serial);
$statement->bindValue(":report_class", $report_class);
$statement->execute();
$recordset = $statement->fetchAll(PDO::FETCH_ASSOC);
$exists = (count($recordset) > 0) ? true : false;
echo json_encode($exists);
}
// ================
// Select a Report.
// ================
public function selectReport() {
$reports = $this->getSelectReportPage();
$popovers = (new Popovers())->getPopovers();
$XML = $this->mergeXML($reports, "<XML/>");
$XML = $this->mergeXML($popovers, $XML);
$content = $this->applyXSL($XML, $this->getXSL("selectReport"));
$this->displayContent($content);
}
// =============
// Setup Reports
// =============
public function setupReports() {
$reports = $this->getReportsPage();
$popovers = (new Popovers())->getPopovers();
$XML = $this->mergeXML($reports, "<XML/>");
$XML = $this->mergeXML($popovers, $XML);
$content = $this->applyXSL($XML, $this->getXSL("setupReports"));
$this->displayContent($content);
}
public function selectReport(){(new RosterReports())->catalog();}
public function setupReports(){(new RosterReports())->catalog();}
}
?>
+97
View File
@@ -0,0 +1,97 @@
<?php
class Roster extends Base {
private static function h(mixed $value): string{return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
private function form(string $step,int $club=0): string {
return '<form method="post" action="main.php" class="row g-2 mb-3"><input type="hidden" name="action" value="Roster.workspace"><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.'">';
}
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
$id=$name.'_'.bin2hex(random_bytes(3));
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>');
}
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>';
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();
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.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
}
$this->displayContent($html.'</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>';
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>';
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>';
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>';
}
public function workspace(): void {
$service=new RosterService();if(!$service->ready()){$this->home();return;}
$clubRaw=$_POST['club_serial']??$_GET['club_serial']??'';$club=($clubRaw===''||$clubRaw==='0')?0:MaintenanceService::id($clubRaw);
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST' && ($_POST['step']??'')!==''){
$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 '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;
case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'');break;
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;
}
$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>';
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>';
}
$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>';
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>';
}
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>';
}
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.='<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());
$this->displayContent($html.'</div>');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
class RosterReports extends Base {
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');
$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'])]);
foreach($history['payments'] as $p)$write(['payment',$name,$p['payment_serial'],$p['due_serial'],$p['member_name'],$p['amount_cents'],$p['paid_on'],$p['payment_method'],$p['payment_reference'],$p['actor']??'Historical',$p['reversal_serial']?'Reversed: '.$p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']:'Received']);
foreach($history['adjustments'] as $a)$write(['dues_adjustment',$name,$a['adjustment_serial'],$a['due_serial'],$a['member_name'],$a['amount_cents'],$a['created_at'],'','',$a['actor'],$a['reason']]);
fclose($file);exit;
}
private function rows(): array {
$service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);$club=$service->club($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;
}
public static function csvText(string $value): string {return preg_match('/\A[\s]*[=+@\-]/u',$value)||preg_match('/\A[\t\r\n]/',$value)?"'".$value:$value;}
public function csv(): void {
$rows=$this->rows();header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-roster.csv"');$file=fopen('php://output','w');
foreach($rows as $row)fputcsv($file,array_map(static fn($cell)=>self::csvText((string)$cell),$row),',','"','');fclose($file);exit;
}
public function excel(): void {
$rows=$this->rows();$book=new Spreadsheet();$sheet=$book->getActiveSheet();$sheet->setTitle('Club roster');
foreach($rows as $r=>$row)foreach($row as $c=>$cell)$sheet->setCellValueExplicit([$c+1,$r+1],(string)$cell,DataType::TYPE_STRING);
$sheet->getStyle('A1:G1')->getFont()->setBold(true);$sheet->freezePane('A2');foreach(range('A','G') as $column)$sheet->getColumnDimension($column)->setAutoSize(true);
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="club-roster.xlsx"');(new Xlsx($book))->save('php://output');exit;
}
public function pdf(): void {
$rows=$this->rows();$pdf=new TCPDF('L');$pdf->SetCreator(self::APPLICATION_NAME);$pdf->SetTitle('Club roster');$pdf->setPrintHeader(false);$pdf->setPrintFooter(false);$pdf->AddPage();$pdf->SetFont('helvetica','',9);
$html='<h1>Club roster</h1><table border="1" cellpadding="4">';foreach($rows as $r=>$row){$html.='<tr>';foreach($row as $cell)$html.=($r===0?'<th>':'<td>').htmlspecialchars((string)$cell,ENT_QUOTES,'UTF-8').($r===0?'</th>':'</td>');$html.='</tr>';}$html.='</table>';$pdf->writeHTML($html);$pdf->Output('club-roster.pdf','D');exit;
}
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>');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?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 {
$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();
}
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($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 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();
}
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;}
public static function cents(string $value): int {
if(!preg_match('/\A(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,2})?\z/',$value))throw new AppException('Enter a nonnegative USD amount with at most two decimal places (maximum 9,999,999.99).');
[$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 {
$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){$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;}
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{
// 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);}
$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;}
}
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;}
}
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);}
}
+2
View File
@@ -12,6 +12,8 @@ class SignUp extends Base {
throw new InvalidArgumentException('Too many account requests. Please try again later.');
}
$auth->register($_POST);
try{(new EmailVerification())->request($_POST['user_email']);}
catch(Throwable $e){error_log('Signup verification delivery failed: '.$e->getMessage());}
$_SESSION['signup_success'] = true;
header('Location: main.php?Xaction=signup', true, 303);
exit;
+15 -111
View File
@@ -26,7 +26,7 @@ class Statuses extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
// ==========
@@ -48,34 +48,7 @@ class Statuses extends Base {
break;
case "add":
$status_name = filter_input(INPUT_POST, "status_name") ?: "";
$status_classes = filter_input(INPUT_POST, "status_classes") ?: "";
$status_active = filter_input(INPUT_POST, "status_active", FILTER_VALIDATE_BOOLEAN) ?: false;
$SQL = "insert into statuses
( status_name,
status_classes,
status_active,
status_creator,
status_created )
VALUES ( :status_name,
:status_classes,
:status_active,
:status_creator,
:status_created )";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":status_name", $status_name);
$statement->bindValue(":status_classes", $status_classes);
$statement->bindValue(":status_active", $status_active, PDO::PARAM_BOOL);
$statement->bindValue(":status_creator", $this->current_user_name);
$statement->bindValue(":status_created", $this->getTimestamp());
$statement->execute();
(new MaintenanceService())->saveStatus(0,$_POST);
break;
@@ -90,19 +63,7 @@ class Statuses extends Base {
// =============
public function deleteStatus() {
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$status = $this->getStatus($status_serial);
if ($status->count() == 0) {
$this->logError("Invalid Status ({$status_serial}). " . __METHOD__);
}
$SQL = "delete from statuses
where status_serial = {$status_serial}";
$this->executeSQL($SQL);
(new MaintenanceService())->deleteStatus(MaintenanceService::id($_POST['status_serial']??null));
}
// ============================
@@ -138,21 +99,10 @@ class Statuses extends Base {
// ======================================
public function statusActive() {
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$active = false;
$SQL = "select workorder_status
from workorders
where workorder_status = {$status_serial}
limit 1";
$workorders = $this->getTable("workorders", $SQL);
$active = ($workorders->count() > 0) ? true : false;
echo json_encode($active);
$stmt=$this->connect()->prepare('SELECT status_default FROM statuses WHERE status_serial=?');
$stmt->execute([MaintenanceService::id($_POST['status_serial']??null)]);$row=$stmt->fetch();
if(!$row)throw new AppException('Status not found.',404);
echo json_encode((bool)$row['status_default']);
}
// =============
@@ -184,41 +134,7 @@ class Statuses extends Base {
break;
case "update":
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$status_name = filter_input(INPUT_POST, "status_name") ?: "";
$status_classes = filter_input(INPUT_POST, "status_classes") ?: "";
$status_active = filter_input(INPUT_POST, "status_active", FILTER_VALIDATE_BOOLEAN) ?: false;
$status_default = filter_input(INPUT_POST, "status_default", FILTER_VALIDATE_BOOLEAN) ?: false;
// Verify the Status
$status = $this->getStatus($status_serial);
if ($status->count() == 0) {
$this->logError("Invalid Status ({$status_serial}). " . __METHOD__);
}
$SQL = "update statuses
set status_name = :status_name,
status_classes = :status_classes,
status_active = :status_active,
status_default = :status_default,
status_changer = :status_changer,
status_changed = :status_changed
where status_serial = :status_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":status_serial", $status_serial);
$statement->bindValue(":status_name", $status_name);
$statement->bindValue(":status_classes", $status_classes);
$statement->bindValue(":status_active", $status_active, PDO::PARAM_BOOL);
$statement->bindValue(":status_default", $status_default, PDO::PARAM_BOOL);
$statement->bindValue(":status_changer", $this->current_user_name);
$statement->bindValue(":status_changed", $this->getTimestamp());
$statement->execute();
(new MaintenanceService())->saveStatus(MaintenanceService::id($_POST['status_serial']??null),$_POST);
break;
@@ -284,13 +200,14 @@ class Statuses extends Base {
// ============================
public function getStatusesPage() {
$this->pagination_size=(int)($_POST['statuses_per_page']??20);
// Find Criteria
$find_status = filter_input(INPUT_POST, "find_status") ?: "";
$find_status = $this->sanitizeString($find_status);
$find_status = trim($find_status);
$find_words = implode("|", explode(" ", $find_status));
$find_words = implode("|", array_map(static fn($word) => preg_quote($word), explode(" ", $find_status)));
// Record Count
@@ -316,21 +233,9 @@ class Statuses extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -338,7 +243,7 @@ class Statuses extends Base {
from view_statuses
where status_name regexp :status_name
or status_classes regexp :status_classes
order by status_name
order by status_name,status_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -358,7 +263,6 @@ class Statuses extends Base {
$statuses->addAttribute("end", $end);
$statuses->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $statuses;
}
+8
View File
@@ -0,0 +1,8 @@
<?php
final class Transport {
public static function https(array $server,string $trustedProxies=''): bool {
if(in_array(strtolower((string)($server['HTTPS']??'')),['on','1','https'],true))return true;
$trusted=array_filter(array_map('trim',explode(',',$trustedProxies)),static fn($ip)=>filter_var($ip,FILTER_VALIDATE_IP)!==false);
return in_array($server['REMOTE_ADDR']??'',$trusted,true)&&strtolower(trim($server['HTTP_X_FORWARDED_PROTO']??''))==='https';
}
}
+30 -193
View File
@@ -28,7 +28,7 @@ class Users extends Base {
$this->current_user_serial = (int) ($_SESSION[self::USER_SERIAL] ?? 0);
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
$this->pagination_page = 1;
}
// ===========
@@ -62,11 +62,11 @@ class Users extends Base {
$this->logError("Invalid User ({$user_serial}). " . __METHOD__);
}
$notes = (new Notes())->getAllNotes($user_serial);
$subscriptions = (new Subscriptions())->getAllUsersSubscriptions($user_serial);
$notes = (new Notes())->getNotes('User',(int)$user_serial);
$activity=$this->getLoginActivity($user_serial);
$XML = $this->mergeXML($user, "<XML/>");
$XML = $this->mergeXML($subscriptions, $XML);
$XML = $this->mergeXML($activity, $XML);
$XML = $this->mergeXML($notes, $XML);
$content = $this->applyXSL($XML, $this->getXSL("userSummary"));
@@ -99,71 +99,7 @@ class Users extends Base {
break;
case "add":
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
http_response_code(422); exit('Enter a valid username, email address and role.');
}
$user_name = strtoupper(preg_replace("/\s+/", " ", $user_name));
$user_random_password = $this->generateRandomPassword();
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
$user_change_password = (int) true;
$user_active = 'active';
// Add the User
$connection = $this->connect();
$SQL = "insert into users
( user_name,
user_email,
user_first_name,
user_password_hash,
user_role,
user_change_password,
user_status,
user_creator,
user_created )
values ( :user_name,
:user_email,
:user_first_name,
:user_password_hash,
:user_role,
:user_change_password,
:user_status,
:user_creator,
:user_created )";
$statement = $connection->prepare($SQL);
$statement->bindValue(":user_name", $user_name);
$statement->bindValue(":user_email", $user_email);
$statement->bindValue(":user_first_name", $user_first_name);
$statement->bindValue(":user_password_hash", $user_temp_password);
$statement->bindValue(":user_role", $user_role);
$statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
$statement->bindValue(":user_status", $user_active);
$statement->bindValue(":user_creator", $this->current_user_name);
$statement->bindValue(":user_created", $this->getTimestamp());
if (!$statement->execute()) {
trigger_error($statement->errorInfo()[2]);
}
$user = $this->getUserByUserName($user_name);
// Send User Credentials
(new AuthService())->requestReset($user_email);
echo json_encode((new AdminAccounts())->create($_POST));
break;
@@ -178,19 +114,7 @@ class Users extends Base {
// ===========
public function deleteUser() {
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$users = $this->getUser($user_serial);
if ($users->count() == 0) {
$this->logError("Invalid User ({$user_serial}): " . __METHOD__);
}
$SQL = "delete from users
where user_serial = {$user_serial}";
$this->executeSQL($SQL);
(new MaintenanceService())->deleteUser(MaintenanceService::id($_POST['user_serial']??null));
}
// =========
@@ -227,50 +151,7 @@ class Users extends Base {
break;
case "update":
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
http_response_code(422); exit('Enter a valid username, email address and role.');
}
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
$users = $this->getUser($user_serial);
if ($users->count() == 0) {
$this->logError("Invalid User ({$user_serial}): " . __METHOD__);
}
// Update the User
$SQL = "update users
set user_name = :user_name,
user_email = :user_email,
user_first_name = :user_first_name,
user_role = :user_role,
user_auth_version = user_auth_version + 1,
user_status = :user_status,
user_changer = :user_changer,
user_changed = :user_changed
where user_serial = :user_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_name", $user_name);
$statement->bindValue(":user_email", $user_email);
$statement->bindValue(":user_first_name", $user_first_name);
$statement->bindValue(":user_role", $user_role);
$statement->bindValue(":user_status", $user_active);
$statement->bindValue(":user_changer", $this->current_user_name);
$statement->bindValue(":user_changed", $this->getTimestamp());
$statement->execute();
(new MaintenanceService())->updateUser(MaintenanceService::id($_POST['user_serial']??null),$_POST);
break;
default:
@@ -309,7 +190,7 @@ class Users extends Base {
public function getUserSerial($user_name = "") {
$users = $this->getUserByUserId($user_name);
$users = $this->getUserByUserName($user_name);
return ($users->count() === 0) ? 0 : (int) $users->record->user_serial;
}
@@ -330,13 +211,14 @@ class Users extends Base {
// ==========================
public function getUsersPage() {
$this->pagination_size=(int)($_POST['users_per_page']??20);
// Find Criteria
$find_user = filter_input(INPUT_POST, "find_user") ?: "";
$find_user = $this->sanitizeString($find_user);
$find_user = trim($find_user);
$find_words = implode("|", explode(" ", $find_user));
$find_words = implode("|", array_map(static fn($word) => preg_quote($word), explode(" ", $find_user)));
// Record Count
@@ -346,6 +228,7 @@ class Users extends Base {
from view_users
where user_name regexp :user_name
or user_first_name regexp :user_first_name
or user_last_name regexp :user_last_name
or user_email regexp :user_email
or user_role regexp :user_role";
@@ -353,6 +236,7 @@ class Users extends Base {
$statement->bindValue(":user_name", $find_words);
$statement->bindValue(":user_first_name", $find_words);
$statement->bindValue(":user_last_name", $find_words);
$statement->bindValue(":user_email", $find_words);
$statement->bindValue(":user_role", $find_words);
@@ -366,21 +250,9 @@ class Users extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
switch ($pagination) {
case "first" : $this->pagination_page = 1;
break;
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
break;
case "next" : $this->pagination_page = ($this->pagination_page + 1);
break;
case "last" : $this->pagination_page = ($count / $this->pagination_size);
break;
}
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
$start = ($offset + 1);
$end = min($count, (($start + $this->pagination_size) - 1));
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -388,8 +260,10 @@ class Users extends Base {
from view_users
where user_name regexp :user_name
or user_first_name regexp :user_first_name
or user_last_name regexp :user_last_name
or user_email regexp :user_email
or user_role regexp :user_role
order by user_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -397,6 +271,7 @@ class Users extends Base {
$statement->bindValue(":user_name", $find_words);
$statement->bindValue(":user_first_name", $find_words);
$statement->bindValue(":user_last_name", $find_words);
$statement->bindValue(":user_email", $find_words);
$statement->bindValue(":user_role", $find_words);
@@ -411,7 +286,6 @@ class Users extends Base {
$users->addAttribute("end", $end);
$users->addAttribute("page", $this->pagination_page);
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $users;
}
@@ -474,18 +348,9 @@ class Users extends Base {
// =============================
public function userActive() {
$user_serial = (int) filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$SQL = "select request_serial
from requests
where request_user = {$user_serial}";
$requests = $this->getTable("requests", $SQL);
$active = ($requests->count() > 0) ? true : $active;
echo json_encode($active);
$id=MaintenanceService::id($_POST['user_serial']??null);
$used=(new MaintenanceService())->userUsed($id);
echo json_encode($used || $id===(int)($_SESSION[self::USER_SERIAL]??0));
}
// ===============
@@ -514,12 +379,10 @@ class Users extends Base {
public function resetPassword() {
$this->requireCsrf();
$stmt = $this->connect()->prepare('SELECT user_email FROM users WHERE user_serial = :id');
$stmt->execute([':id' => (int) ($_POST['user_serial'] ?? 0)]);
$email = $stmt->fetchColumn();
if ($email) (new AuthService())->requestReset($email);
header('Content-Type: application/json');
echo json_encode(true);
$stmt=$this->connect()->prepare('SELECT user_email FROM users WHERE user_serial=?');$stmt->execute([MaintenanceService::id($_POST['user_serial']??null)]);$email=$stmt->fetchColumn();
if(!$email)throw new AppException('Account not found.',404);
if(!(new AuthService())->requestReset($email))throw new AppException('The invitation is rate limited or the account is inactive. Try again later.',429);
echo json_encode(['ok'=>true,'message'=>'A password setup link was sent.']);
}
// ===============================================
@@ -545,27 +408,7 @@ class Users extends Base {
// ====================
public function updateActiveStatus() {
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
$SQL = "update users
set user_status = :user_status,
user_auth_version = user_auth_version + 1,
user_changed = :user_changed,
user_changer = :user_changer
where user_serial = :user_serial";
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_status", $user_active);
$statement->bindValue(":user_changed", $this->getTimestamp());
$statement->bindValue(":user_changer", $this->current_user_name);
if (!$statement->execute()) {
error_log($statement->errorInfo()[2]);
}
(new MaintenanceService())->setUserActive(MaintenanceService::id($_POST['user_serial']??null),MaintenanceService::boolean($_POST['user_active']??null));
}
// ====================================
@@ -573,14 +416,8 @@ class Users extends Base {
// ====================================
public function getLoginActivity($userserial) {
$SQL = "select view_journal.journal_timestamp_verbose, view_journal.journal_ip
from view_journal
where view_journal.journal_origin = '{$userserial}'
and view_journal.journal_entry like '{$userserial} %logged in%'";
return $this->getTable("loginactivity", $SQL);
$stmt=$this->connect()->prepare("SELECT journal_timestamp_verbose,journal_ip FROM view_journal WHERE journal_origin=? AND journal_entry LIKE ? ORDER BY journal_timestamp DESC,journal_serial DESC");
$id=MaintenanceService::id($userserial);$stmt->execute([(string)$id,$id.' logged in%']);
return $this->getTableXML('loginactivity',$stmt);
}
}
?>
+28
View File
@@ -0,0 +1,28 @@
# Substitute certificate paths, document root, and PHP socket before installation.
# This server terminates HTTPS directly. Forwarded headers are not trusted by default.
server {
listen 80;
server_name app.myhuntingclubroster.com;
return 301 https://app.myhuntingclubroster.com$request_uri;
}
server {
listen 443 ssl;
server_name app.myhuntingclubroster.com;
ssl_certificate /etc/ssl/hcr/fullchain.pem;
ssl_certificate_key /etc/ssl/hcr/privkey.pem;
root /srv/hcr/current;
index main.php;
autoindex off;
client_max_body_size 1m;
location ~ ^/(classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports|deploy)(/|$) { return 404; }
location ~ /\. { return 404; }
location ~* \.(json|lock|xml|sql|md|ya?ml|toml|log|cnf|env|zip|bak)$ { return 404; }
location = /main.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/main.php;
fastcgi_param HTTPS on;
fastcgi_pass unix:/run/php/hcr.sock;
}
location ~ \.php(?:/|$) { return 404; }
location / { try_files $uri $uri/ =404; }
}
+11
View File
@@ -0,0 +1,11 @@
display_errors = Off
log_errors = On
expose_php = Off
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.cookie_secure = 1
; Place logs and session storage outside the document root with restricted access.
; error_log = /var/log/hcr/php-errors.log
; session.save_path = /var/lib/hcr/sessions
+24
View File
@@ -0,0 +1,24 @@
-- Run as a DBA after provisioning the account with a strong externally managed
-- password and the correct application host restriction. Do not run from PHP.
-- Substitute the account/host/database to match the production deployment.
GRANT SELECT ON huntingclubroster.* TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.users TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.password_resets TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.auth_attempts TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, DELETE ON huntingclubroster.email_verifications TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.alerts TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.dropdowns TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.dropdowntypes TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.notes TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.popovers TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.statuses TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.journal TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE ON huntingclubroster.clubs TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE ON huntingclubroster.club_memberships TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_seasons TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_dues TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_payments TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_due_adjustments TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_payment_reversals TO 'hcr_runtime'@'app-host.example';
-- No schema-changing privileges, journal UPDATE/DELETE, or financial UPDATE/DELETE.
-- Use separate credentials for migrations and backup/restore jobs.
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#183c30">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="The MyHuntingClubRoster application is coming soon. Please check back for updates.">
<title>Application Coming Soon | MyHuntingClubRoster</title>
<style>
:root { color-scheme: light; font-family: "Segoe UI", Arial, sans-serif; color: #203b30; background: #f5f4ee; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; }
header { padding: 28px clamp(20px, 5vw, 72px); border-bottom: 1px solid #dce1d7; }
.brand { display: flex; align-items: center; gap: 12px; font-size: clamp(16px, 3vw, 21px); font-weight: 700; letter-spacing: -.5px; }
.brand svg { width: 36px; height: 36px; flex-shrink: 0; color: #285548; }
main { flex: 1; display: grid; place-items: center; padding: 56px 20px; }
.panel { width: 100%; max-width: 700px; text-align: center; }
.illustration { display: block; width: 160px; height: 160px; margin: 0 auto 30px; }
.status { display: inline-flex; align-items: center; gap: 9px; margin: 0 0 22px; padding: 8px 14px; border: 1px solid #d4dfd2; border-radius: 24px; color: #385747; background: #eaf0e5; font-size: 11px; font-weight: 700; letter-spacing: 1.8px; text-transform: uppercase; }
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #527454; }
h1 { margin: 0; font: 400 clamp(42px, 7vw, 68px)/1.1 Georgia, "Times New Roman", serif; letter-spacing: -.045em; text-wrap: balance; }
.description { max-width: 470px; margin: 24px auto 0; color: #5b695f; font-size: 17px; line-height: 1.8; text-wrap: pretty; }
.note { margin: 20px 0 32px; color: #5b695f; font-size: 14px; line-height: 1.7; }
.button { display: inline-flex; justify-content: center; align-items: center; gap: 12px; min-height: 48px; padding: 13px 23px; background: #285548; border: 1px solid #285548; border-radius: 8px; color: #fff; text-decoration: none; font-size: 14px; font-weight: 600; transition: background .15s; }
.button:hover { background: #193c32; }
.button:focus-visible { outline: 3px solid #a07b42; outline-offset: 4px; }
.button svg { width: 17px; height: 17px; }
footer { padding: 22px 20px; text-align: center; color: #657367; font-size: 12px; line-height: 1.8; overflow-wrap: anywhere; }
footer span { display: block; color: #7a837a; font-size: 11px; }
@media (max-width: 480px) { header { padding: 20px; } main { padding: 40px 22px; } .illustration { width: 130px; height: 130px; margin-bottom: 24px; } .description { font-size: 16px; } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
</style>
</head>
<body>
<header>
<div class="brand">
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true"><path d="M20 4 5 31h30L20 4Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m12 31 8-15 8 15M20 24v12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
MyHuntingClubRoster
</div>
</header>
<main>
<section class="panel" aria-labelledby="page-title">
<svg class="illustration" viewBox="0 0 160 160" fill="none" aria-hidden="true">
<circle cx="80" cy="80" r="79" fill="#e5ebdf"/>
<circle cx="109" cy="48" r="18" fill="#d5bd8f"/>
<path d="M15 110 53 67l32 37 21-24 40 37" fill="#b9cbb2"/>
<path d="m30 124 30-54 30 54H30Z" fill="#52755b"/>
<path d="m65 129 32-70 34 70H65Z" fill="#285548"/>
<path d="M60 113v23m37-23v25" stroke="#183c30" stroke-width="5" stroke-linecap="round"/>
<path d="M33 138h94" stroke="#a5b69d" stroke-width="2" stroke-linecap="round"/>
</svg>
<p class="status">Application coming soon</p>
<h1>We’re getting ready<br>for your club.</h1>
<p class="description">The MyHuntingClubRoster application is still being prepared. We’re putting the finishing touches in place before opening the doors.</p>
<p class="note">Please check back soon. Thanks for your patience.</p>
<a class="button" href="https://myhuntingclubroster.com/">
Visit our website
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
</section>
</main>
<footer>app.myhuntingclubroster.com<span>MyHuntingClubRoster · OneSource IT Solutions</span></footer>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#183c30">
<meta name="description" content="MyHuntRoster is coming soon. A new season is on the horizon. Check back for updates.">
<title>Coming Soon | MyHuntRoster</title>
<style>
:root { color-scheme: light; --forest: #183c30; --cream: #f6f5f0; --gold: #c5a578; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; min-height: 100svh; background: var(--cream); color: var(--forest); font-family: "Segoe UI", Arial, sans-serif; }
.page { min-height: 100vh; min-height: 100svh; display: grid; grid-template-columns: 1.1fr 1fr; }
.content { display: flex; flex-direction: column; padding: clamp(24px, 5vw, 80px); }
.brand { display: flex; align-items: center; gap: 12px; font-size: 21px; font-weight: 700; letter-spacing: -.7px; }
.brand svg { width: 36px; height: 36px; }
main { margin: auto 0; padding: 80px 0; max-width: 590px; }
.eyebrow { display: inline-flex; align-items: center; gap: 10px; margin: 0 0 28px; font-size: 11px; font-weight: 700; letter-spacing: 2.4px; text-transform: uppercase; }
.eyebrow::before { content: ""; width: 7px; height: 7px; background: #8a6b3f; border-radius: 50%; }
h1 { margin: 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(54px, 6.5vw, 100px); font-weight: 400; line-height: 1.04; letter-spacing: -.055em; }
h1 em { color: #657963; font-weight: 400; }
.description { max-width: 390px; margin: 30px 0 0; color: #56665c; font-size: 17px; line-height: 1.8; }
.launch-note { display: flex; gap: 14px; align-items: center; margin-top: 44px; padding-top: 24px; border-top: 1px solid #d8dfd5; max-width: 390px; }
.launch-note svg { flex-shrink: 0; width: 23px; height: 23px; }
.launch-note p { margin: 0; font-size: 13px; line-height: 1.7; color: #56665c; }
.launch-note strong { display: block; color: var(--forest); font-weight: 600; }
footer { font-size: 11px; color: #647367; letter-spacing: .5px; }
.landscape { position: relative; display: flex; flex-direction: column; justify-content: space-between; min-height: 640px; overflow: hidden; background: #183c30; color: #e5e9d9; }
.landscape::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, transparent 50%, #0b241caa); }
.scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.scene-label { z-index: 1; align-self: flex-end; margin: 40px; padding: 9px 14px; border: 1px solid #e5e9d94d; border-radius: 30px; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; }
.scene-caption { z-index: 1; margin: 48px; }
.scene-caption span { display: block; margin-bottom: 14px; font-size: 10px; letter-spacing: 3px; text-transform: uppercase; color: #c5cfbc; }
.scene-caption p { margin: 0; max-width: 340px; font: 32px/1.25 Georgia, serif; letter-spacing: -.6px; }
@media (min-width: 1600px) { .content { padding-left: max(80px, calc((100vw - 1440px) / 2)); } }
@media (max-width: 760px) {
.page { grid-template-columns: 1fr; }
.content { padding: 28px; min-height: 75svh; }
main { padding: 64px 0; }
h1 { font-size: clamp(54px, 12vw, 80px); }
.landscape { min-height: 400px; }
.scene-label { margin: 28px; }
.scene-caption { margin: 28px; }
.scene-caption p { font-size: 28px; }
}
</style>
</head>
<body>
<div class="page">
<div class="content">
<header class="brand">
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true"><path d="M20 4 5 31h30L20 4Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m12 31 8-15 8 15M20 24v12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
MyHuntRoster
</header>
<main>
<p class="eyebrow">Coming soon</p>
<h1>A new season.<br><em>A fresh start.</em></h1>
<p class="description">Good things are taking shape behind the scenes. We’re getting MyHuntRoster ready for the next chapter of your hunting club.</p>
<div class="launch-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2" stroke-linecap="round"/></svg>
<p><strong>We’re preparing for launch.</strong>Check back soon. We’ll see you out there.</p>
</div>
</main>
<footer>MyHuntRoster &nbsp; / &nbsp; Built for the traditions you share.</footer>
</div>
<aside class="landscape" aria-label="Illustration of a quiet forest at sunrise">
<svg class="scene" viewBox="0 0 700 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<defs>
<linearGradient id="sky" x2="0" y2="1"><stop stop-color="#718b75"/><stop offset="1" stop-color="#c7c6a0"/></linearGradient>
<linearGradient id="ridge" x2="0" y2="1"><stop stop-color="#466652"/><stop offset="1" stop-color="#284c3b"/></linearGradient>
<g id="pine"><path d="M0-130-38-55h19l-34 55h29l-39 64H63L24 0h29L19-55h19Z" fill="currentColor"/><path d="M-5 50H5v55H-5Z" fill="currentColor"/></g>
</defs>
<path fill="url(#sky)" d="M0 0h700v1000H0z"/>
<circle cx="410" cy="310" r="92" fill="#e4d6a7" opacity=".85"/>
<path d="M0 440 120 365 230 425 360 345 510 430 620 370 700 405V1000H0Z" fill="#84977b"/>
<path d="m0 535 150-105 140 90 180-105 230 100v485H0Z" fill="#637e64"/>
<path d="m0 600 190-95 160 65 170-70 180 95v405H0Z" fill="url(#ridge)"/>
<g color="#385d46"><use href="#pine" transform="translate(60 590) scale(.7)"/><use href="#pine" transform="translate(150 570) scale(.5)"/><use href="#pine" transform="translate(545 570) scale(.8)"/><use href="#pine" transform="translate(650 610) scale(.6)"/></g>
<path d="M0 720Q175 590 340 705T700 665V1000H0Z" fill="#244c39"/>
<g color="#193e2e"><use href="#pine" transform="translate(60 760) scale(1.4)"/><use href="#pine" transform="translate(175 795) scale(1)"/><use href="#pine" transform="translate(590 730) scale(1.5)"/><use href="#pine" transform="translate(710 800) scale(1.8)"/></g>
<path d="M0 890Q240 740 430 855T700 820V1000H0Z" fill="#123425"/>
<g color="#102e22"><use href="#pine" transform="translate(-15 900) scale(2.2)"/><use href="#pine" transform="translate(490 990) scale(1.7)"/></g>
</svg>
<div class="scene-label">The next chapter</div>
<div class="scene-caption"><span>Rooted in tradition</span><p>More time for what<br>brings us together.</p></div>
</aside>
</div>
</body>
</html>
+10 -9
View File
@@ -94,22 +94,22 @@ $(document).ready(function () {
// Show Calendar Buttons
// ---------------------
$(".btnAlertStartDateCalendar").on("click", function (e) {
$(".btnAlertStartDateCalendar").on("click", async function (e) {
e.preventDefault();
$("#alert_starts_date").focus().datepicker("show");
});
$(".btnAlertExpiresDateCalendar").on("click", function (e) {
$(".btnAlertExpiresDateCalendar").on("click", async function (e) {
e.preventDefault();
$("#alert_expires_date").focus().datepicker("show");
});
$("#alert_starts_date").on("blur", function () {
$("#alert_starts_date").on("blur", async function () {
$("#alert_starts_time").focus();
});
$("#alert_expires_date").on("blur", function () {
$("#alert_expires_date").on("blur", async function () {
$("#alert_expires_time").focus();
});
@@ -117,7 +117,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -129,7 +129,7 @@ $(document).ready(function () {
// Cancel Button
// -------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addAlert || "";
@@ -143,7 +143,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addAlert").submit(function (e) {
$("#addAlert").submit(async function (e) {
e.preventDefault();
var alert_title = $("#alert_title"),
alert_starts_date = $("#alert_starts_date"),
@@ -162,7 +163,7 @@ $(document).ready(function () {
}
if ((!isBlank(alert_starts_date)) && (!isDate(alert_starts_date))) {
if ((!isBlank(alert_starts_date)) && (!await isDate(alert_starts_date))) {
createInputError(alert_starts_date, "Date is invalid");
return false;
}
@@ -182,7 +183,7 @@ $(document).ready(function () {
return false;
}
if ((!isBlank(alert_expires_date)) && (!isDate(alert_expires_date))) {
if ((!isBlank(alert_expires_date)) && (!await isDate(alert_expires_date))) {
createInputError(alert_expires_date, "Date is invalid");
return false;
}
+5 -4
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#addDropdown").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
formSubmit({action: $sessionStorage.addDropdown});
});
@@ -29,7 +29,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addDropdown").submit(function (e) {
$("#addDropdown").submit(async function (e) {
e.preventDefault();
var dropdowntype_serial = $("#dropdowntype_serial").val(),
dropdown_value = $("#dropdown_value");
@@ -43,7 +44,7 @@ $(document).ready(function () {
return false;
}
if (formPost({action: "Dropdowns.dropdownExists", dropdowntype_serial: dropdowntype_serial, dropdown_serial: 0, dropdown_value: dropdown_value.val()}) === true) {
if (await formPost({action: "Dropdowns.dropdownExists", dropdowntype_serial: dropdowntype_serial, dropdown_serial: 0, dropdown_value: dropdown_value.val()}) === true) {
createInputError(dropdown_value, "Value already exists");
return false;
}
+8 -17
View File
@@ -3,7 +3,7 @@
// Add Dropdowntype
// ================
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Save Button
// -----------
$(".btnSave").on("click", function(e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#addDropdowntype").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function() {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function() {
$(".btnCancel").on("click", async function () {
formSubmit({ action : $sessionStorage.addDropdowntype });
});
@@ -29,7 +29,8 @@ $(document).ready(function() {
// Validate Inputs
// ---------------
$("#addDropdowntype").submit( function(e) {
$("#addDropdowntype").submit( async function (e) {
e.preventDefault();
var dropdowntype_name = $("#dropdowntype_name"),
dropdowntype_title = $("#dropdowntype_title"),
@@ -48,7 +49,7 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Dropdowns.dropdowntypeExists", dropdowntype_serial : 0, dropdowntype_name : dropdowntype_name.val() }) === true) {
if (await formPost({ action : "Dropdowns.dropdowntypeExists", dropdowntype_serial : 0, dropdowntype_name : dropdowntype_name.val() }) === true) {
createInputError(dropdowntype_name, "Name already exists");
return false;
}
@@ -58,26 +59,16 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Dropdowns.dropdowntypetitleExists", dropdowntype_serial : 0, dropdowntype_title : dropdowntype_title.val() }) === true) {
if (await formPost({ action : "Dropdowns.dropdowntypetitleExists", dropdowntype_serial : 0, dropdowntype_title : dropdowntype_title.val() }) === true) {
createInputError(dropdowntype_title, "Title already exists");
return false;
}
if (isBlank(dropdowntype_table)) {
createInputError(dropdowntype_table, "Table is required");
return false;
}
if (isBlank(dropdowntype_column)) {
createInputError(dropdowntype_column, "Column is required");
return false;
}
e.preventDefault();
$.post("./", $(this).serialize())
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.addDropdowntype || "";
$sessionStorage.toast = "Dropdown Type was Added";
+4 -3
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -24,7 +24,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addNote || "";
@@ -38,7 +38,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addNote").submit(function (e) {
$("#addNote").submit(async function (e) {
e.preventDefault();
var note_text = $("#note_text");
+5 -4
View File
@@ -59,7 +59,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -71,7 +71,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addPopover || "";
@@ -85,7 +85,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addPopover").submit(function (e) {
$("#addPopover").submit(async function (e) {
e.preventDefault();
var popover_name = $("#popover_name"),
popover_title = $("#popover_title"),
@@ -101,7 +102,7 @@ $(document).ready(function () {
return false;
}
if (formPost({action: "Popovers.popoverExists", popover_serial: 0, popover_name: popover_name.val()}) === true) {
if (await formPost({action: "Popovers.popoverExists", popover_serial: 0, popover_name: popover_name.val()}) === true) {
createInputError(popover_name, "Name already exists");
return false;
}
+5 -4
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -24,7 +24,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addReport || "";
@@ -38,7 +38,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addReport").submit(function (e) {
$("#addReport").submit(async function (e) {
e.preventDefault();
var report_name = $("#report_name"),
report_class = $("#report_class"),
@@ -59,7 +60,7 @@ $(document).ready(function () {
return false;
}
if (formPost({action: "Reports.reportExists", report_serial: 0, report_class: report_class.val()}) === true) {
if (await formPost({action: "Reports.reportExists", report_serial: 0, report_class: report_class.val()}) === true) {
createInputError(report_class, "Class already exists");
return false;
}
+7 -6
View File
@@ -3,7 +3,7 @@
// Add Status
// ==========
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Save Button
// -----------
$(".btnSave").on("click", function(e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#addStatus").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function() {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function() {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addStatus || "";
@@ -35,7 +35,8 @@ $(document).ready(function() {
// Validate Inputs
// ---------------
$("#addStatus").submit( function(e) {
$("#addStatus").submit( async function (e) {
e.preventDefault();
var status_name = $("#status_name"),
status_classes = $("#status_classes");
@@ -50,7 +51,7 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Statuses.statusExists", status_serial : 0, status_name : status_name.val() }) === true) {
if (await formPost({ action : "Statuses.statusExists", status_serial : 0, status_name : status_name.val() }) === true) {
createInputError(status_name, "Name already exists");
return false;
}
@@ -59,7 +60,7 @@ $(document).ready(function() {
$.post("./", $(this).serialize())
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.addStatus || "";
$sessionStorage.toast = "Status was Added";
+13 -9
View File
@@ -27,11 +27,11 @@ $(document).ready(function () {
// Get the User Information
// ------------------------
$("#user_name").on("change", function () {
$("#user_name").on("change", async function () {
clearInputErrors();
let user = formPost({action: "Users.getUser_AJAX", user_name: $(this).val()});
let user = await formPost({action: "Users.getUser_AJAX", user_name: $(this).val()});
if (user.exists === true) {
createInputError($(this), "User ID already exists");
@@ -44,7 +44,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#addUser").submit();
});
@@ -53,7 +53,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.addUser || "";
@@ -66,7 +66,7 @@ $(document).ready(function () {
// Toggle Show Password
// --------------------
$("#togglePassword").click(function () {
$("#togglePassword").click(async function () {
var passwordField = $("#user_password");
var type = passwordField.attr("type") === "password" ? "text" : "password";
passwordField.attr("type", type);
@@ -80,7 +80,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#addUser").submit(function (e) {
$("#addUser").submit(async function (e) {
e.preventDefault();
var user_name = $("#user_name"),
user_first_name = $("#user_first_name"),
@@ -96,7 +97,7 @@ $(document).ready(function () {
return false;
}
let user = formPost({action: "Users.getUser_AJAX", user_name: user_name.val()});
let user = await formPost({action: "Users.getUser_AJAX", user_name: user_name.val()});
if (user.valid === false) {
createInputError(user_name, "User Name is invalid");
@@ -109,7 +110,7 @@ $(document).ready(function () {
}
if (isBlank(user_first_name)) {
createInputError(user_first_name, "Client Name is required");
createInputError(user_first_name, "First name is required");
return false;
}
@@ -118,6 +119,7 @@ $(document).ready(function () {
return false;
}
if (isBlank($('#user_last_name'))) { createInputError($('#user_last_name'),'Last name is required');return false; }
if ($("option:selected", user_role).val() === "") {
createInputError(user_role, "Role is required");
return false;
@@ -127,7 +129,9 @@ $(document).ready(function () {
e.preventDefault();
$.post("./", $(this).serialize())
.done(function () {
.done(function (result) {
$sessionStorage.toast=result.warning || 'Account created and invitation sent.';
sessionStorage.setItem($application,JSON.stringify($sessionStorage));
formSubmit({action: $sessionStorage.addUser});
});
+2 -2
View File
@@ -17,10 +17,10 @@ function resetLogoutTimer() {
startLogoutTimer();
}
function logoutUser() {
async function logoutUser() {
// Redirect to a PHP logout script
formPost({action: "signout"});
await formPost({action: "signout"});
window.location.reload();
}
+9 -83
View File
@@ -1,84 +1,10 @@
// =====================
// Auto Save and Restore
// =====================
var $sessionStorage = JSON.parse(sessionStorage.getItem("ATLHOUSINGREPORT") || "{}");
// -------------------
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function (e) {
switch (e.target.type) {
case "text" :
$sessionStorage[e.target.name] = $(this).val();
break;
case "radio" :
$sessionStorage[e.target.name] = $(this).val();
break;
case "checkbox" :
$sessionStorage[e.target.name] = $(this).prop("checked");
break;
case "select-one" :
$sessionStorage[e.target.name] = $(this).val();
break;
case "hidden" :
$sessionStorage[e.target.name] = $(this).val();
break;
default :
$sessionStorage[e.target.name] = $(this).val();
break;
}
sessionStorage.setItem("HOMESTEAD", JSON.stringify($sessionStorage));
$(function(){
const application=$('#APPLICATION_MNEMONIC').val();if(!application)return;
const key=application+':autosave:'+window.location.pathname;
let saved={};try{saved=JSON.parse(sessionStorage.getItem(key)||'{}');}catch(error){sessionStorage.removeItem(key);}
$('.autosave').filter(':not([type="password"]):not([type="hidden"])').each(function(){
if(Object.prototype.hasOwnProperty.call(saved,this.name)){
if(this.type==='checkbox')$(this).prop('checked',!!saved[this.name]);else $(this).val(saved[this.name]);
}
}).on('change',function(){saved[this.name]=this.type==='checkbox'?this.checked:$(this).val();sessionStorage.setItem(key,JSON.stringify(saved));});
});
// ----------------
// Initalize Inputs
// ----------------
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name=" + name + "]");
if ($input.length > 0) {
switch ($input.prop("type")) {
case "text" :
$input.val(value);
break;
case "radio" :
$input.filter("[value=" + value + "]").prop("checked", true);
break;
case "checkbox" :
$input.prop("checked", value);
break;
case "select-one" :
$input.val(value);
break;
case "hidden" :
$input.val(value);
break;
default :
$input.val(value);
break;
}
}
});
// ------------------
// Save preset Inputs
// ------------------
$("input[type=radio]:checked").trigger("change");
+44 -96
View File
@@ -109,48 +109,16 @@ function isURL(object) {
// AJAX Call to Get Zip Code Detail.
// ---------------------------------
function getZipCodeDetail(zipcode) {
var zipCodeDetail = null;
$.ajax({
url: "./",
type: "POST",
async: false,
data: {action: "get_zipcode_detail",
zip_code: zipcode},
dataType: "json",
success: function (info) {
zipCodeDetail = info;
}
});
return zipCodeDetail;
async function getZipCodeDetail(zipcode) {
return await formPost({action:'getZipcode',zip_code:zipcode});
}
// -----------------------------
// AJAX Call to Validate a Date.
// -----------------------------
function isDate(date) {
var valid = false;
$.ajax({
url: "./",
type: "POST",
async: false,
data: {action: "validateDate",
date: date.val()},
dataType: "json",
success: function (result) {
valid = result;
}
});
return valid;
async function isDate(date) {
return await formPost({action:'validateDate',date:date.val()});
}
// ---------------------------------------------
@@ -208,71 +176,44 @@ function trimString(string) {
// -------------------
function formPost(inputs) {
var response = "";
$.ajax({
url: "./",
type: "POST",
async: false,
dataType: "json",
data: inputs,
success: function (results) {
response = results;
}, error: function (errorcode) {
console.log(errorcode);
}
});
return response;
return $.ajax({url:'./',type:'POST',dataType:'json',data:inputs});
}
// --------------
// Submit a Form.
// --------------
//function formSubmit(inputs, target) {
//
// $('<form action="./" method="post" id="formSubmit"/>').appendTo("body");
//
// var form = $("#formSubmit");
//
// if (target === "_blank") {
// form.prop("target", target);
// }
//
// for (var name in inputs) {
// form.append('<input type="hidden" name="' + name + '" value="' + inputs[name] + '"/>');
// }
//
// console.log($('#formSubmit').html());
//
//// form.submit();
//
//}
// --------------
// Submit a Form.
// --------------
function formSubmit(inputs, target = "") {
const form=$("#formSubmit");
form.prop('target',target);
form.find('input').not('[name="csrf_token"]').remove();
Object.entries(inputs).forEach(([name,value])=>{
if(name!=='csrf_token' && value!==undefined && value!==null)$('<input>',{type:'hidden',name:name,value:String(value)}).appendTo(form);
});
form.get(0).submit();
}
var form = $("#formSubmit");
if (target === "_blank") {
form.prop("target", target);
} else {
form.prop("target", "");
}
for (var name in inputs) {
form.append('<input type="hidden" name="' + name + '" value="' + inputs[name] + '"/>');
}
form.submit();
$(document).ajaxError(function(event,xhr){
const message=(xhr.responseJSON && xhr.responseJSON.error) || (xhr.status===401?'Your session has expired. Reload the page to sign in.':'The request failed. Please try again.');
showRequestMessage(message,'danger');
});
$(document).ajaxStart(function(){
$('.btnSave,.btnConfirmDialogConfirm').filter(':enabled').addClass('request-busy').prop('disabled',true).attr('aria-busy','true');
}).ajaxStop(function(){
$('.request-busy').prop('disabled',false).removeClass('request-busy').removeAttr('aria-busy');
});
// jQuery reports these failures through ajaxError above. Stop the rejected event
// handler without also producing an unhandled promise error in the browser.
window.addEventListener('unhandledrejection',function(event){
if(event.reason && typeof event.reason.status==='number')event.preventDefault();
});
function showRequestMessage(message,kind='info'){
let box=$('#request-message');
let target=$('.modal.show .modal-body').first();
if(!target.length)target=$('.hr-workspace,main,body').first();
if(!box.length)box=$('<div>',{id:'request-message',role:'alert',tabindex:'-1'});
box.prependTo(target);
box.attr('class','alert alert-'+kind).text(message).trigger('focus');
}
$(document).ready(function () {
@@ -283,8 +224,15 @@ $(document).ready(function () {
// Enable Tooltips and Popovers
// ----------------------------
$("[data-bs-toggle=tooltip]").tooltip({trigger: "hover", container: "body"});
$("[data-bs-toggle=popover]").popover({trigger: "focus", container: "body"});
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(element=>{
const title=element.getAttribute('data-bs-title')||element.getAttribute('title')||'';
if(title)bootstrap.Tooltip.getOrCreateInstance(element,{title:title,trigger:'hover',container:'body'});
});
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(element=>{
const title=element.getAttribute('data-bs-title')||element.getAttribute('title')||'';
const content=element.getAttribute('data-bs-content')||'';
if(title||content)bootstrap.Popover.getOrCreateInstance(element,{title:title,content:content,trigger:'focus',container:'body'});
});
// -------------
// Display Toast
@@ -294,7 +242,7 @@ $(document).ready(function () {
if (toast !== "") {
$(".toast-body").html(toast);
$(".toast-body").text(toast);
$("#toast-message").toast("show");
+6
View File
@@ -0,0 +1,6 @@
if (/msie\s|trident/i.test(window.navigator.userAgent)) {
window.location.href = 'html/unsupported.html';
}
document.addEventListener('click', function (event) {
if (event.target.closest('[data-app-return]')) window.location.href = 'main.php';
});
+14 -129
View File
@@ -11,81 +11,6 @@ $(document).ready(function () {
// Navbar Links
// ============
// ------------
// Manage Links
// ------------
$(".user-subscriptions").on("click", function () {
$sessionStorage.action = "StripePayments.checkout";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
console.log($sessionStorage);
formSubmit($sessionStorage);
});
// -------
// Support
// -------
$(".start-support-ticket").on("click", function () {
$sessionStorage.action = "SupportTickets.startSupportTicket";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
console.log($sessionStorage);
formSubmit($sessionStorage);
});
$(".manage-permits").on("click", function () {
$sessionStorage.action = "Permits.managePermits";
$sessionStorage.pagination = "first";
$sessionStorage.find_permit = "";
$sessionStorage.permits_order_by = "";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// -------------
// Consolidation
// -------------
$(".rdi-consolidation").on("click", function () {
$sessionStorage.action = "Consolidation.consolidationSequence";
$sessionStorage.step = "prompt";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// -----------------
// Import Permit CSV
// -----------------
$(".import-permit-csv").on("click", function () {
$sessionStorage.action = "Import.uploadCSVFile";
$sessionStorage.step = "prompt";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// =============
// Reports Links
// =============
@@ -94,7 +19,7 @@ $(document).ready(function () {
// Select Report
// -------------
$(".select-report").on("click", function () {
$(".select-report").on("click", async function () {
$sessionStorage.action = "Reports.selectReport";
$sessionStorage.pagination = "first";
@@ -110,26 +35,11 @@ $(document).ready(function () {
// Setup Links
// ===========
// --------
// Counties
// --------
$(".setup-counties").on("click", function () {
$sessionStorage.action = "Counties.setupCounties";
$sessionStorage.pagination = "first";
$sessionStorage.find_county = "";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// -----
// Users
// -----
$(".setup-users").on("click", function () {
$(".setup-users").on("click", async function () {
$sessionStorage.action = "Users.setupUsers";
$sessionStorage.pagination = "first";
@@ -141,27 +51,11 @@ $(document).ready(function () {
});
// -------------
// Subscriptions
// -------------
$(".setup-subscriptions").on("click", function () {
$sessionStorage.action = "Subscriptions.setupSubscriptions";
$sessionStorage.pagination = "first";
$sessionStorage.find_subscription = "";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// ---------
// Dropdowns
// ---------
$(".setup-dropdowns").on("click", function () {
$(".setup-dropdowns").on("click", async function () {
sessionStorage.removeItem($application);
@@ -178,9 +72,9 @@ $(document).ready(function () {
// Toggle Dark/Light Theme
// -----------------------
$(".toggle-theme").on("click", function () {
$(".toggle-theme").on("click", async function () {
formPost({action: "toggleTheme"});
await formPost({action: "toggleTheme"});
if (document.documentElement.getAttribute("data-bs-theme") === "dark") {
document.documentElement.setAttribute("data-bs-theme", "light");
@@ -194,9 +88,9 @@ $(document).ready(function () {
// Sign out system
// ---------------
$(".sign-out").on("click", function () {
$(".sign-out").on("click", async function () {
formPost({action: "signout"});
await formPost({action: "signout"});
window.location.reload();
@@ -206,7 +100,7 @@ $(document).ready(function () {
// Setup Dropdown Types
// --------------------
$(".setup-dropdowntypes").on("click", function () {
$(".setup-dropdowntypes").on("click", async function () {
$sessionStorage.action = "Dropdowns.setupDropdowntypes";
$sessionStorage.pagination = "first";
@@ -221,7 +115,7 @@ $(document).ready(function () {
// View Journal
// ------------
$(".view-journal").on("click", function () {
$(".view-journal").on("click", async function () {
$sessionStorage.action = "Journal.viewJournal";
$sessionStorage.pagination = "first";
@@ -237,22 +131,13 @@ $(document).ready(function () {
// View Support Tickets
// --------------------
$(".view-support-tickets").on("click", function () {
$sessionStorage.action = "SupportTickets.viewSupportTickets";
$sessionStorage.pagination = "first";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// --------------
// Setup Popovers
// --------------
$(".setup-popovers").on("click", function () {
$(".setup-popovers").on("click", async function () {
$sessionStorage.action = "Popovers.setupPopovers";
$sessionStorage.pagination = "first";
@@ -268,7 +153,7 @@ $(document).ready(function () {
// Setup Alerts
// --------------
$(".setup-alerts").on("click", function () {
$(".setup-alerts").on("click", async function () {
$sessionStorage.action = "Alerts.setupAlerts";
$sessionStorage.pagination = "first";
@@ -285,7 +170,7 @@ $(document).ready(function () {
// Setup Reports
// -------------
$(".setup-reports").on("click", function () {
$(".setup-reports").on("click", async function () {
$sessionStorage.action = "Reports.setupReports";
$sessionStorage.pagination = "first";
@@ -301,7 +186,7 @@ $(document).ready(function () {
// Setup Statuses
// --------------
$(".setup-statuses").on("click", function () {
$(".setup-statuses").on("click", async function () {
$sessionStorage.action = "Statuses.setupStatuses";
$sessionStorage.pagination = "first";
@@ -317,7 +202,7 @@ $(document).ready(function () {
// Setup Statuses
// --------------
$(".run-developer-stub").on("click", function () {
$(".run-developer-stub").on("click", async function () {
$sessionStorage.action = "testing";
+10 -9
View File
@@ -94,22 +94,22 @@ $(document).ready(function () {
// Show Calendar Buttons
// ---------------------
$(".btnAlertStartDateCalendar").on("click", function (e) {
$(".btnAlertStartDateCalendar").on("click", async function (e) {
e.preventDefault();
$("#alert_starts_date").focus().datepicker("show");
});
$(".btnAlertExpiresDateCalendar").on("click", function (e) {
$(".btnAlertExpiresDateCalendar").on("click", async function (e) {
e.preventDefault();
$("#alert_expires_date").focus().datepicker("show");
});
$("#alert_starts_date").on("blur", function () {
$("#alert_starts_date").on("blur", async function () {
$("#alert_starts_time").focus();
});
$("#alert_expires_date").on("blur", function () {
$("#alert_expires_date").on("blur", async function () {
$("#alert_expires_time").focus();
});
@@ -117,7 +117,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -129,7 +129,7 @@ $(document).ready(function () {
// Cancel Button
// -------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editAlert || "";
@@ -143,7 +143,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#editAlert").submit(function (e) {
$("#editAlert").submit(async function (e) {
e.preventDefault();
var alert_title = $("#alert_title"),
alert_starts_date = $("#alert_starts_date"),
@@ -162,7 +163,7 @@ $(document).ready(function () {
}
if ((!isBlank(alert_starts_date)) && (!isDate(alert_starts_date))) {
if ((!isBlank(alert_starts_date)) && (!await isDate(alert_starts_date))) {
createInputError(alert_starts_date, "Date is invalid");
return false;
}
@@ -182,7 +183,7 @@ $(document).ready(function () {
return false;
}
if ((!isBlank(alert_expires_date)) && (!isDate(alert_expires_date))) {
if ((!isBlank(alert_expires_date)) && (!await isDate(alert_expires_date))) {
createInputError(alert_expires_date, "Date is invalid");
return false;
}
+11 -10
View File
@@ -3,7 +3,7 @@
// Edit Dropdown
// =============
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Save Button
// -----------
$(".btnSave").on("click", function(e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#editDropdown").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function() {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function() {
$(".btnCancel").on("click", async function () {
formSubmit({ action : $sessionStorage.editDropdown });
});
@@ -29,9 +29,9 @@ $(document).ready(function() {
// Delete Button
// -------------
$(".btnDelete").on("click", function() {
$(".btnDelete").on("click", async function () {
if (formPost({ action : "Dropdowns.dropdownActive", dropdown_serial : $sessionStorage.dropdown_serial }) === true) {
if (await formPost({ action : "Dropdowns.dropdownActive", dropdown_serial : $sessionStorage.dropdown_serial }) === true) {
$("#continue-dialog").find(".modal-title").html("Cannot Delete Dropdown").end()
.find(".modal-body").html("This Dropdown is in use and cannot be deleted.").end()
@@ -48,11 +48,11 @@ $(document).ready(function() {
});
$("body").on("click", ".confirm-delete-dropdown .btnConfirmDialogConfirm", function() {
$("body").on("click", ".confirm-delete-dropdown .btnConfirmDialogConfirm", async function () {
$.post("./", { action : "Dropdowns.deleteDropdown", dropdown_serial : $sessionStorage.dropdown_serial })
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.editDropdown || "";
$sessionStorage.toast = "Dropdown was Deleted";
@@ -69,7 +69,8 @@ $(document).ready(function() {
// Validate Inputs
// ---------------
$("#editDropdown").submit( function(e) {
$("#editDropdown").submit( async function (e) {
e.preventDefault();
var dropdowntype_serial = $("#dropdowntype_serial").val(),
dropdown_serial = $("#dropdown_serial").val(),
@@ -84,7 +85,7 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Dropdowns.dropdownExists", dropdowntype_serial : dropdowntype_serial, dropdown_serial : dropdown_serial, dropdown_value : dropdown_value.val() }) === true) {
if (await formPost({ action : "Dropdowns.dropdownExists", dropdowntype_serial : dropdowntype_serial, dropdown_serial : dropdown_serial, dropdown_value : dropdown_value.val() }) === true) {
createInputError(dropdown_value, "Value already exists");
return false;
}
@@ -93,7 +94,7 @@ $(document).ready(function() {
$.post("./", $(this).serialize())
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.editDropdown || "";
$sessionStorage.toast = "Dropdown was Saved";
+12 -21
View File
@@ -3,7 +3,7 @@
// Edit Dropdowntype
// =================
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Save Button
// -----------
$(".btnSave").on("click", function(e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#editDropdowntype").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function() {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function() {
$(".btnCancel").on("click", async function () {
formSubmit({ action : $sessionStorage.editDropdowntype });
});
@@ -29,9 +29,9 @@ $(document).ready(function() {
// Delete Button
// -------------
$(".btnDelete").on("click", function() {
$(".btnDelete").on("click", async function () {
if (formPost({ action : "Dropdowns.dropdowntypeActive", dropdowntype_serial : $sessionStorage.dropdowntype_serial }) === true) {
if (await formPost({ action : "Dropdowns.dropdowntypeActive", dropdowntype_serial : $sessionStorage.dropdowntype_serial }) === true) {
$("#continue-dialog").find(".modal-title").html("Cannot Delete Dropdown Type").end()
.find(".modal-body").html("Dropdown Type is in use and cannot be deleted.").end()
@@ -48,11 +48,11 @@ $(document).ready(function() {
});
$("body").on("click", ".confirm-delete-dropdowntype .btnConfirmDialogConfirm", function() {
$("body").on("click", ".confirm-delete-dropdowntype .btnConfirmDialogConfirm", async function () {
$.post("./", { action : "Dropdowns.deleteDropdowntype", dropdowntype_serial : $sessionStorage.dropdowntype_serial })
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.editDropdowntype || "";
$sessionStorage.toast = "Dropdown Type was Deleted";
@@ -69,7 +69,8 @@ $(document).ready(function() {
// Validate Inputs
// ---------------
$("#editDropdowntype").submit( function(e) {
$("#editDropdowntype").submit( async function (e) {
e.preventDefault();
var dropdowntype_serial = $("#dropdowntype_serial"),
dropdowntype_name = $("#dropdowntype_name"),
@@ -89,7 +90,7 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Dropdowns.dropdowntypeExists", dropdowntype_serial : dropdowntype_serial.val(), dropdowntype_name : dropdowntype_name.val() }) === true) {
if (await formPost({ action : "Dropdowns.dropdowntypeExists", dropdowntype_serial : dropdowntype_serial.val(), dropdowntype_name : dropdowntype_name.val() }) === true) {
createInputError(dropdowntype_name, "Name already exists");
return false;
}
@@ -99,26 +100,16 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Dropdowns.dropdowntypetitleExists", dropdowntype_serial : dropdowntype_serial.val(), dropdowntype_title : dropdowntype_title.val() }) === true) {
if (await formPost({ action : "Dropdowns.dropdowntypetitleExists", dropdowntype_serial : dropdowntype_serial.val(), dropdowntype_title : dropdowntype_title.val() }) === true) {
createInputError(dropdowntype_title, "Title already exists");
return false;
}
if (isBlank(dropdowntype_table)) {
createInputError(dropdowntype_table, "Table is required");
return false;
}
if (isBlank(dropdowntype_column)) {
createInputError(dropdowntype_column, "Column is required");
return false;
}
e.preventDefault();
$.post("./", $(this).serialize())
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.editDropdowntype || "";
$sessionStorage.toast = "Dropdown Type was Saved";
+6 -5
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -24,7 +24,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editNote || "";
@@ -38,7 +38,7 @@ $(document).ready(function () {
// Delete Button
// -------------
$(".btnDelete").on("click", function () {
$(".btnDelete").on("click", async function () {
$("#confirm-dialog").removeClass().addClass("modal confirm-delete-note")
.find(".modal-title").html("Confirm Delete Note").end()
@@ -47,7 +47,7 @@ $(document).ready(function () {
});
$("body").on("click", ".confirm-delete-note .btnConfirmDialogConfirm", function () {
$("body").on("click", ".confirm-delete-note .btnConfirmDialogConfirm", async function () {
$.post("./", {action: "Notes.deleteNote", note_serial: $sessionStorage.note_serial})
.done(function () {
@@ -61,7 +61,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#editNote").submit(function (e) {
$("#editNote").submit(async function (e) {
e.preventDefault();
var note_text = $("#note_text");
+7 -6
View File
@@ -59,7 +59,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -71,7 +71,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editPopover;
@@ -85,7 +85,7 @@ $(document).ready(function () {
// Delete Button
// -------------
$(".btnDelete").on("click", function () {
$(".btnDelete").on("click", async function () {
$("#confirm-dialog").removeClass().addClass("modal confirm-delete-popover")
.find(".modal-title").html("Confirm Delete Popover").end()
@@ -94,7 +94,7 @@ $(document).ready(function () {
});
$("body").on("click", ".confirm-delete-popover .btnConfirmDialogConfirm", function () {
$("body").on("click", ".confirm-delete-popover .btnConfirmDialogConfirm", async function () {
$.post("./", {action: "Popovers.deletePopover", popover_serial: $sessionStorage.popover_serial})
@@ -114,7 +114,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#editPopover").submit(function (e) {
$("#editPopover").submit(async function (e) {
e.preventDefault();
var popover_serial = $("#popover_serial"),
popover_name = $("#popover_name"),
@@ -131,7 +132,7 @@ $(document).ready(function () {
return false;
}
if (formPost({action: "Popovers.popoverExists", popover_serial: popover_serial.val(), popover_name: popover_name.val()}) === true) {
if (await formPost({action: "Popovers.popoverExists", popover_serial: popover_serial.val(), popover_name: popover_name.val()}) === true) {
createInputError(popover_name, "Name already exists");
return false;
}
+7 -6
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -24,7 +24,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editReport || "";
@@ -38,7 +38,7 @@ $(document).ready(function () {
// Delete Button
// -------------
$(".btnDelete").on("click", function () {
$(".btnDelete").on("click", async function () {
$("#confirm-dialog").removeClass().addClass("modal confirm-delete-report")
.find(".modal-title").html("Confirm Delete Report").end()
@@ -47,7 +47,7 @@ $(document).ready(function () {
});
$("body").on("click", ".confirm-delete-report .btnConfirmDialogConfirm", function () {
$("body").on("click", ".confirm-delete-report .btnConfirmDialogConfirm", async function () {
$.post("./", {action: "Reports.deleteReport", report_serial: $sessionStorage.report_serial})
@@ -68,7 +68,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#editReport").submit(function (e) {
$("#editReport").submit(async function (e) {
e.preventDefault();
var report_name = $("#report_name"),
report_serial = $("#report_serial"),
@@ -90,7 +91,7 @@ $(document).ready(function () {
return false;
}
if (formPost({action: "Reports.reportExists", report_serial: report_serial.val(), report_class: report_class.val()}) === true) {
if (await formPost({action: "Reports.reportExists", report_serial: report_serial.val(), report_class: report_class.val()}) === true) {
createInputError(report_class, "Class already exists");
return false;
}
+11 -10
View File
@@ -3,7 +3,7 @@
// Edit Status
// ===========
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Save Button
// -----------
$(".btnSave").on("click", function(e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
$("#editStatus").submit();
});
@@ -21,7 +21,7 @@ $(document).ready(function() {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function() {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editStatus || "";
@@ -35,9 +35,9 @@ $(document).ready(function() {
// Delete Button
// -------------
$(".btnDelete").on("click", function() {
$(".btnDelete").on("click", async function () {
if (formPost({ action : "Statuses.statusActive", status_serial : $sessionStorage.status_serial }) === true) {
if (await formPost({ action : "Statuses.statusActive", status_serial : $sessionStorage.status_serial }) === true) {
$("#continue-dialog").find(".modal-title").html("Cannot Delete Status").end()
.find(".modal-body").html("This Status is in use and cannot be deleted.").end()
@@ -54,10 +54,10 @@ $(document).ready(function() {
});
$("body").on("click", ".confirm-delete-status .btnConfirmDialogConfirm", function() {
$("body").on("click", ".confirm-delete-status .btnConfirmDialogConfirm", async function () {
$.post("./", { action : "Statuses.deleteStatus", status_serial : $sessionStorage.status_serial })
.done(function() {
.done(function () {
formSubmit({ action : $sessionStorage.deleteStatus });
});
@@ -67,7 +67,8 @@ $(document).ready(function() {
// Validate Inputs
// ---------------
$("#editStatus").submit( function(e) {
$("#editStatus").submit( async function (e) {
e.preventDefault();
var status_serial = $("#status_serial"),
status_name = $("#status_name"),
@@ -83,7 +84,7 @@ $(document).ready(function() {
return false;
}
if (formPost({ action : "Statuses.statusExists", status_serial : status_serial.val(), status_name : status_name.val() }) === true) {
if (await formPost({ action : "Statuses.statusExists", status_serial : status_serial.val(), status_name : status_name.val() }) === true) {
createInputError(status_name, "Name already exists");
return false;
}
@@ -92,7 +93,7 @@ $(document).ready(function() {
$.post("./", $(this).serialize())
.done(function() {
.done(function () {
$sessionStorage.action = $sessionStorage.editStatus || "";
$sessionStorage.toast = "Status was Saved";
+10 -8
View File
@@ -26,7 +26,7 @@ $(document).ready(function () {
// Save Button
// -----------
$(".btnSave").on("click", function (e) {
$(".btnSave").on("click", async function (e) {
e.preventDefault();
@@ -38,7 +38,7 @@ $(document).ready(function () {
// Cancel Button.
// --------------
$(".btnCancel").on("click", function () {
$(".btnCancel").on("click", async function () {
$sessionStorage.action = $sessionStorage.editUser || "";
@@ -52,9 +52,9 @@ $(document).ready(function () {
// Delete Button
// -------------
$(".btnDelete").on("click", function () {
$(".btnDelete").on("click", async function () {
if (formPost({action: "Users.userActive", user_serial: $sessionStorage.user_serial}) === true) {
if (await formPost({action: "Users.userActive", user_serial: $sessionStorage.user_serial}) === true) {
$("#continue-dialog").find(".modal-title").html("Cannot Delete User").end()
.find(".modal-body").html("This User is Active and cannot be deleted.").end()
@@ -71,7 +71,7 @@ $(document).ready(function () {
});
$("body").on("click", ".confirm-delete-user .btnConfirmDialogConfirm", function () {
$("body").on("click", ".confirm-delete-user .btnConfirmDialogConfirm", async function () {
$.post("./", {action: "Users.deleteUser", user_serial: $sessionStorage.user_serial})
@@ -92,7 +92,8 @@ $(document).ready(function () {
// Validate Inputs
// ---------------
$("#editUser").submit(function (e) {
$("#editUser").submit(async function (e) {
e.preventDefault();
var user_name = $("#user_name"),
user_email = $("#user_email"),
@@ -101,7 +102,7 @@ $(document).ready(function () {
clearInputErrors();
user_name.val(titleCase(user_name.val()));
user_name.val(trimString(user_name.val()));
user_email.val(trimString(user_email.val()));
if (isBlank(user_name)) {
@@ -115,10 +116,11 @@ $(document).ready(function () {
}
if (isBlank(user_first_name)) {
createInputError(user_first_name, "Client Name is required");
createInputError(user_first_name, "First name is required");
return false;
}
if (isBlank($('#user_last_name'))) { createInputError($('#user_last_name'),'Last name is required');return false; }
if ($("option:selected", user_role).val() === "") {
createInputError(user_role, "Role is required");
return false;
-4
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
+12 -12
View File
@@ -3,7 +3,7 @@
// Select Report
// =============
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Find Button
// -----------
$(".btnFind").on("click", function() {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Reports.selectReport";
$sessionStorage.pagination = "first";
@@ -25,7 +25,7 @@ $(document).ready(function() {
});
$("#select_report").on("keyup", function(e) {
$("#select_report").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -35,7 +35,7 @@ $(document).ready(function() {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function() {
$(".btnClearFind").on("click", async function () {
$("#select_report").val("").trigger("change");
@@ -54,7 +54,7 @@ $(document).ready(function() {
// Show Per Page Change
// --------------------
$("#reports_per_page").on("change", function() {
$("#reports_per_page").on("change", async function () {
$sessionStorage.alerts_per_page = $(this).val();
$sessionStorage.action = "Reports.selectReport";
@@ -72,7 +72,7 @@ $(document).ready(function() {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function() {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Reports.selectReport";
$sessionStorage.pagination = $(this).data("pagination");
@@ -93,7 +93,7 @@ $(document).ready(function() {
// Done Button
// -----------
$(".btnDone").on("click", function() {
$(".btnDone").on("click", async function () {
formSubmit({ action : "" });
@@ -103,7 +103,7 @@ $(document).ready(function() {
// Process the selected Report
// ---------------------------
$("#reports-table tbody").on("click touch", "tr", function(e) {
$("#reports-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -166,7 +166,7 @@ $(document).ready(function() {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function(e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -188,9 +188,9 @@ $(document).ready(function() {
// Initalize Inputs
// ----------------
$.each($sessionStorage, function(name, value) {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -220,7 +220,7 @@ $(document).ready(function() {
// Per Page Dropdown
// -----------------
$("#reports_per_page").on("change", function() {
$("#reports_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+10 -10
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Find Button
// -----------
$(".btnFind").on("click", function () {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Alerts.setupAlerts";
$sessionStorage.pagination = "first";
@@ -25,7 +25,7 @@ $(document).ready(function () {
});
$("#find_alert").on("keyup", function (e) {
$("#find_alert").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -35,7 +35,7 @@ $(document).ready(function () {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function () {
$(".btnClearFind").on("click", async function () {
$("#find_alert").val("").trigger("change");
@@ -54,7 +54,7 @@ $(document).ready(function () {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function () {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Alerts.setupAlerts";
$sessionStorage.pagination = $(this).data("pagination");
@@ -75,7 +75,7 @@ $(document).ready(function () {
// Add Button
// ----------
$(".btnAdd").on("click", function () {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Alerts.addAlert";
$sessionStorage.addAlert = "Alerts.setupAlerts";
@@ -90,7 +90,7 @@ $(document).ready(function () {
// Done Button
// -----------
$(".btnDone").on("click", function () {
$(".btnDone").on("click", async function () {
formSubmit({action: ""});
@@ -100,7 +100,7 @@ $(document).ready(function () {
// Edit
// ----
$("#alerts-table tbody").on("click touch", "tr", function (e) {
$("#alerts-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -118,7 +118,7 @@ $(document).ready(function () {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function (e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -154,7 +154,7 @@ $(document).ready(function () {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -196,7 +196,7 @@ $(document).ready(function () {
// Per Page Dropdown
// -----------------
$("#alerts_per_page").on("change", function () {
$("#alerts_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+20 -33
View File
@@ -3,7 +3,7 @@
// Setup Dropdowns
// ===============
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -26,11 +26,11 @@ $(document).ready(function() {
// Save open Accordion
// -------------------
$("#dropdown-accordion").on("shown.bs.collapse hidden.bs.collapse", function(e) {
$("#dropdown-accordion").on("shown.bs.collapse hidden.bs.collapse", async function (e) {
const open = new Array();
$(".collapse.show").each(function() {
$(".collapse.show").each(function () {
open.push($(this).attr("id"));
});
@@ -43,7 +43,7 @@ $(document).ready(function() {
// Add Button
// ----------
$(".btnAdd").on("click", function() {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Dropdowns.addDropdown";
$sessionStorage.dropdowntype_serial = $(this).data("dropdowntype-serial");
@@ -59,7 +59,7 @@ $(document).ready(function() {
// Done Button
// -----------
$(".btnDone").on("click", function() {
$(".btnDone").on("click", async function () {
formSubmit({ action : "" });
@@ -69,7 +69,7 @@ $(document).ready(function() {
// Edit
// ----
$(".dropdown-table tbody td:not('.dropdown_default, .dropdown_active')").on("click touch", function(e) {
$(".dropdown-table tbody td:not('.dropdown_default, .dropdown_active')").on("click touch", async function (e) {
e.preventDefault();
@@ -87,40 +87,27 @@ $(document).ready(function() {
// Process the Default Checkbox
// ----------------------------
$(".dropdown_default").on("click", function(e) {
// Get the clicked Checkbox's current value.
var $this = $(e.target),
checked = ($this.closest("tr").find("input[name='dropdown_default']").prop("checked"));
// Uncheck all Checkboxes.
$this.closest("tbody").find("input[name='dropdown_default']").each(function() {
$(this).prop("checked", false);
});
// Toggle the selected Checkbox.
$this.closest("tr").find("input[name='dropdown_default']").prop("checked", !checked);
// Update the Dropdowns Table with the chosen Default.
var dropdown_serial = $(e.target).closest("tr").data("dropdown-serial"),
dropdown_dropdowntype = $(e.target).closest("tr").data("dropdown-dropdowntype"),
dropdown_checked = !checked;
formPost({action : "Dropdowns.updateDropdownDefault", dropdown_serial : dropdown_serial, dropdown_dropdowntype : dropdown_dropdowntype, dropdown_checked: dropdown_checked});
const savedDefaults=new Map();
$('input[name="dropdown_default"]').each(function(){savedDefaults.set($(this).closest('tr').data('dropdown-serial'),this.checked);});
$('input[name="dropdown_default"]').on('change',async function(){
const box=$(this),row=box.closest('tr'),id=row.data('dropdown-serial'),type=row.data('dropdown-dropdowntype'),checked=this.checked;
box.prop('disabled',true);
try{
await formPost({action:'Dropdowns.updateDropdownDefault',dropdown_serial:id,dropdown_dropdowntype:type,dropdown_checked:checked});
$('input[name="dropdown_default"]').each(function(){
const parent=$(this).closest('tr');if(parent.data('dropdown-dropdowntype')===type){const selected=parent.data('dropdown-serial')===id?checked:false;$(this).prop('checked',selected);savedDefaults.set(parent.data('dropdown-serial'),selected);}
});
}catch(error){$('input[name="dropdown_default"]').each(function(){$(this).prop('checked',!!savedDefaults.get($(this).closest('tr').data('dropdown-serial')));});}
finally{box.prop('disabled',false);}
});
// ---------------------------
// Process the Active Checkbox
// ---------------------------
$("input[name='dropdown_active']").on("change", function(e) {
$("input[name='dropdown_active']").on("change", async function (e) {
formPost({ action : "Dropdowns.updateDropdownActive", dropdown_serial : $(this).data("dropdown-serial"), dropdown_checked: $(this).prop("checked") });
try { await formPost({ action : "Dropdowns.updateDropdownActive", dropdown_serial : $(this).data("dropdown-serial"), dropdown_checked: $(this).prop("checked") }); } catch(error) { $(this).prop("checked", !$(this).prop("checked")); }
});
+12 -12
View File
@@ -3,7 +3,7 @@
// Setup Dropdowntypes
// ===================
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Find Button
// -----------
$(".btnFind").on("click", function() {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Dropdowns.setupDropdowntypes";
$sessionStorage.pagination = "first";
@@ -25,7 +25,7 @@ $(document).ready(function() {
});
$("#find_dropdowntype").on("keyup", function(e) {
$("#find_dropdowntype").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -35,7 +35,7 @@ $(document).ready(function() {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function() {
$(".btnClearFind").on("click", async function () {
$("#find_dropdowntype").val("").trigger("change");
@@ -54,7 +54,7 @@ $(document).ready(function() {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function() {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Dropdowns.setupDropdowntypes";
$sessionStorage.pagination = $(this).data("pagination");
@@ -75,7 +75,7 @@ $(document).ready(function() {
// Add Button
// ----------
$(".btnAdd").on("click", function() {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Dropdowns.addDropdowntype";
$sessionStorage.addDropdowntype = "Dropdowns.setupDropdowntypes";
@@ -90,7 +90,7 @@ $(document).ready(function() {
// Done Button
// -----------
$(".btnDone").on("click", function() {
$(".btnDone").on("click", async function () {
formSubmit({ action : "" });
@@ -100,7 +100,7 @@ $(document).ready(function() {
// Edit
// ----
$("#dropdowntypes-table tbody").on("click touch", "tr", function(e) {
$("#dropdowntypes-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -118,7 +118,7 @@ $(document).ready(function() {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function(e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -140,9 +140,9 @@ $(document).ready(function() {
// Initalize Inputs
// ----------------
$.each($sessionStorage, function(name, value) {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -172,7 +172,7 @@ $(document).ready(function() {
// Per Page Dropdown
// -----------------
$("#dropdowntypes_per_page").on("change", function() {
$("#dropdowntypes_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+10 -10
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Find Button
// -----------
$(".btnFind").on("click", function () {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Popovers.setupPopovers";
$sessionStorage.pagination = "first";
@@ -25,7 +25,7 @@ $(document).ready(function () {
});
$("#find_popover").on("keyup", function (e) {
$("#find_popover").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -35,7 +35,7 @@ $(document).ready(function () {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function () {
$(".btnClearFind").on("click", async function () {
$("#find_popover").val("").trigger("change");
@@ -54,7 +54,7 @@ $(document).ready(function () {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function () {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Popovers.setupPopovers";
$sessionStorage.pagination = $(this).data("pagination");
@@ -75,7 +75,7 @@ $(document).ready(function () {
// Add Button
// ----------
$(".btnAdd").on("click", function () {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Popovers.addPopover";
$sessionStorage.addPopover = "Popovers.setupPopovers";
@@ -90,7 +90,7 @@ $(document).ready(function () {
// Done Button
// -----------
$(".btnDone").on("click", function () {
$(".btnDone").on("click", async function () {
formSubmit({action: ""});
@@ -100,7 +100,7 @@ $(document).ready(function () {
// Edit
// ----
$("#popovers-table tbody").on("click touch", "tr", function (e) {
$("#popovers-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -118,7 +118,7 @@ $(document).ready(function () {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function (e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -154,7 +154,7 @@ $(document).ready(function () {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -196,7 +196,7 @@ $(document).ready(function () {
// Per Page Dropdown
// -----------------
$("#popovers_per_page").on("change", function () {
$("#popovers_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+12 -12
View File
@@ -3,7 +3,7 @@
// Setup Reports
// =============
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Find Button
// -----------
$(".btnFind").on("click", function() {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Reports.setupReports";
$sessionStorage.pagination = "first";
@@ -23,7 +23,7 @@ $(document).ready(function() {
});
$("#find_report").on("keyup", function(e) {
$("#find_report").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -33,7 +33,7 @@ $(document).ready(function() {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function() {
$(".btnClearFind").on("click", async function () {
$("#find_report").val("").trigger("change");
@@ -50,7 +50,7 @@ $(document).ready(function() {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function() {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Reports.setupReports";
$sessionStorage.pagination = $(this).data("pagination");
@@ -69,7 +69,7 @@ $(document).ready(function() {
// Add Button
// ----------
$(".btnAdd").on("click", function() {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Reports.addReport";
$sessionStorage.addReport = "Reports.setupReports";
@@ -84,7 +84,7 @@ $(document).ready(function() {
// Done Button
// -----------
$(".btnDone").on("click", function() {
$(".btnDone").on("click", async function () {
formSubmit({ action : "" });
@@ -94,7 +94,7 @@ $(document).ready(function() {
// Edit
// ----
$("#reports-table tbody").on("click touch", "tr", function(e) {
$("#reports-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -112,7 +112,7 @@ $(document).ready(function() {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function(e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -134,9 +134,9 @@ $(document).ready(function() {
// Initalize Inputs
// ----------------
$.each($sessionStorage, function(name, value) {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -166,7 +166,7 @@ $(document).ready(function() {
// Per Page Dropdown
// -----------------
$("#reports_per_page").on("change", function() {
$("#reports_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+10 -9
View File
@@ -12,7 +12,7 @@ $(document).ready(function () {
// Find Button
// -----------
$(".btnFind").on("click", function () {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Statuses.setupStatuses";
$sessionStorage.pagination = "first";
@@ -23,7 +23,7 @@ $(document).ready(function () {
});
$("#find_status").on("keyup", function (e) {
$("#find_status").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -33,7 +33,7 @@ $(document).ready(function () {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function () {
$(".btnClearFind").on("click", async function () {
$("#find_status").val("").trigger("change");
@@ -50,7 +50,7 @@ $(document).ready(function () {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function () {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Statuses.setupStatuses";
$sessionStorage.pagination = $(this).data("pagination");
@@ -69,7 +69,7 @@ $(document).ready(function () {
// Add Button
// ----------
$(".btnAdd").on("click", function () {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Statuses.addStatus";
$sessionStorage.addStatus = "Statuses.setupStatuses";
@@ -84,7 +84,7 @@ $(document).ready(function () {
// Done Button
// -----------
$(".btnDone").on("click", function () {
$(".btnDone").on("click", async function () {
formSubmit({action: ""});
@@ -94,7 +94,7 @@ $(document).ready(function () {
// Edit
// ----
$("#statuses-table tbody").on("click touch", "tr", function (e) {
$("#statuses-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -113,7 +113,7 @@ $(document).ready(function () {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function (e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -149,7 +149,7 @@ $(document).ready(function () {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -187,4 +187,5 @@ $(document).ready(function () {
$("input[type=radio]:checked").trigger("change");
$('#statuses_per_page').on('change',function(){$('.btnFind').trigger('click');});
});
+11 -11
View File
@@ -11,7 +11,7 @@ $(document).ready(function () {
// Find Button
// -----------
$(".btnFind").on("click", function () {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Users.setupUsers";
$sessionStorage.pagination = "first";
@@ -24,7 +24,7 @@ $(document).ready(function () {
});
$("#find_user").on("keyup", function (e) {
$("#find_user").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -34,7 +34,7 @@ $(document).ready(function () {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function () {
$(".btnClearFind").on("click", async function () {
$("#find_user").val("").trigger("change");
@@ -53,7 +53,7 @@ $(document).ready(function () {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function () {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Users.setupUsers";
$sessionStorage.pagination = $(this).data("pagination");
@@ -74,7 +74,7 @@ $(document).ready(function () {
// Add User Button
// ---------------
$(".btnAdd").on("click", function () {
$(".btnAdd").on("click", async function () {
$sessionStorage.action = "Users.addUser";
$sessionStorage.addUser = "Users.setupUsers";
@@ -89,7 +89,7 @@ $(document).ready(function () {
// Done Button
// -----------
$(".btnDone").on("click", function () {
$(".btnDone").on("click", async function () {
formSubmit({action: ""});
@@ -99,7 +99,7 @@ $(document).ready(function () {
// Edit
// ----
// $("#users-table tbody").on("click touch", "tr", function (e) {
// $("#users-table tbody").on("click touch", "tr", async function (e) {
//
// e.preventDefault();
//
@@ -117,7 +117,7 @@ $(document).ready(function () {
// User Summary
// ------------
$("#users-table tbody").on("click touch", "tr", function (e) {
$("#users-table tbody").on("click touch", "tr", async function (e) {
e.preventDefault();
@@ -136,7 +136,7 @@ $(document).ready(function () {
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function (e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -172,7 +172,7 @@ $(document).ready(function () {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -214,7 +214,7 @@ $(document).ready(function () {
// Per Page Dropdown
// -----------------
$("#users_per_page").on("change", function () {
$("#users_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+15 -283
View File
@@ -1,283 +1,15 @@
// ============
// User Summary
// ============
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
var default_tab = "Subscriptions";
var user_role = $("#USER_ROLE").val();
//
// --------------
// Select Picker.
// --------------
$(".search-dropdown").selectpicker({noneSelectedText: "",
style: "",
styleBase: "form-select form-select-sm",
iconBase: "bi",
virtualScroll: true,
size: 10,
selectOnTab: true,
containter: "body",
liveSearch: true});
// -----------
// Input Masks
// -----------
$(".date-mask").inputmask({alias: "datetime",
inputFormat: "mm/dd/yyyy",
placeholder: "MM/DD/YYYY",
showMaskOnHover: false});
$(".numeric-mask").inputmask("9{0,9}", {showMaskOnHover: false});
$(".numeric-2-mask").inputmask({alias: "numeric",
showMaskOnHover: false,
placeholder: "0",
groupSeparator: ",",
allowMinus: false,
digitsOptional: false,
digits: 2});
// ------------
// Date Pickers
// ------------
$("#user_completed").datepicker({format: "mm-dd-yyyy",
container: "div.user_completed",
keyboardNavigation: false,
showOnFocus: false,
autoclose: true,
endDate: "0d",
orientation: "left bottom",
todayBtn: "linked",
todayHighlight: true});
// Disable standard Drag/Drop processing.
$("html").on("dragenter dragover dragleave drop", function (e) {
e.preventDefault();
});
// ---------------------------
// Set and Show the Active Tab
// --------------------------
$sessionStorage.userSummary_tab = $sessionStorage.userSummary_tab || default_tab;
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
$("#tab-" + ($sessionStorage.userSummary_tab || default_tab)).tab("show");
// --------------------
// Capture a Tab Change
// --------------------
$(".nav-tabs a[data-bs-toggle='tab']").on("shown.bs.tab", function (e) {
$sessionStorage.userSummary_tab = $(e.target).attr("data-tab");
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
});
// ----------------------
// Active Status Dropdown
// ----------------------
$("#user_active").on("change", function () {
formPost({action: "Users.updateActiveStatus", user_serial: $sessionStorage.user_serial, user_active: $(this).val()});
location.reload();
});
// --------------------
// Reset Users Password
// ---------------------
$(".btnResetPassword").on("click", function () {
formPost({action: "Users.resetPassword", user_serial: $sessionStorage.user_serial});
$sessionStorage.toast = "Passsword Reset";
location.reload();
});
// ==============
// Action Buttons
// ==============
// -----------
// Done Button
// -----------
$(".btnDone").on("click", function () {
$sessionStorage.action = $sessionStorage.userSummary || "";
formSubmit($sessionStorage);
});
// -----------
// Edit Button
// -----------
$(".btnEdit").on("click", function () {
if (user_role === "Guest") {
return false;
}
$sessionStorage.action = "Users.editUser";
$sessionStorage.editUser = "Users.userSummary";
$sessionStorage.deleteUser = "Users.setupUsers";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// ------------
// Print Button
// ------------
$(".btnPrint").on("click", function (e) {
e.preventDefault();
window.open("", "PrintTab");
$("input[name = 'user_serial']", "#PrintUserSummaryForm").val($sessionStorage.user_serial);
$("#PrintUserSummaryForm").submit();
});
// -------------
// Delete Button
// -------------
$(".btnDelete").on("click", function () {
$("#confirm-dialog").removeClass().addClass("modal confirm-delete-user")
.find(".modal-title").html("Confirm Delete User").end()
.find(".modal-body").html("Are you sure you wish to delete this User?").end()
.modal("show");
});
$("body").on("click", ".confirm-delete-user .btnConfirmDialogConfirm", function () {
$.post("./", {action: "Users.deleteUser", user_serial: $sessionStorage.user_serial})
.done(function () {
$sessionStorage.action = $sessionStorage.deleteUser || "";
$sessionStorage.toast = "User was Deleted";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
});
// =========
// Notes Tab
// =========
// ---------------
// Add Note Button
// ---------------
$(".btnAddNote").on("click", function () {
$sessionStorage.action = "Notes.addNote";
$sessionStorage.note_type = "User";
$sessionStorage.note_reference = $sessionStorage.user_serial;
$sessionStorage.addNote = "Users.userSummary";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// ---------
// Edit Note
// ---------
$("#notes-table tbody").on("click touch", "tr", function (e) {
e.preventDefault();
if (user_role === "Guest") {
return false;
}
$sessionStorage.action = "Notes.editNote";
$sessionStorage.note_serial = $(this).data("note-serial");
$sessionStorage.editNote = "Users.userSummary";
$sessionStorage.deleteNote = "Users.userSummary";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// =================
// Subscriptions Tab
// =================
// -------------------------------
// Add Subscription to User Button
// -------------------------------
$(".btnAddSubscription").on("click", function () {
$sessionStorage.action = "Subscriptions.addSubscriptionToUser";
$sessionStorage.user_reference = $sessionStorage.user_serial;
$sessionStorage.addSubscriptionToUser = "Users.userSummary";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
// --------------------------------
// Edit Subscription to User Button
// --------------------------------
$("#usersubscriptions-table tbody").on("click touch", "tr", function (e) {
e.preventDefault();
if (user_role === "Guest") {
return false;
}
$sessionStorage.action = "Subscriptions.editUsersSubscription";
$sessionStorage.usersubscription_serial = $(this).data("usersubscription-serial");
$sessionStorage.editUsersSubscription = "Users.userSummary";
$sessionStorage.deleteUsersSubscription = "Users.userSummary";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
formSubmit($sessionStorage);
});
});
$(function(){
const id=$('#summary_user_serial').val(), application=$('#APPLICATION_MNEMONIC').val();
function summaryReturn(action,extra={}){
const state={action:action,user_serial:id,userSummary:'Users.setupUsers',addNote:'Users.userSummary',editNote:'Users.userSummary',deleteNote:'Users.userSummary',editUser:'Users.userSummary',deleteUser:'Users.setupUsers',...extra};
sessionStorage.setItem(application,JSON.stringify(state));formSubmit(state);
}
$('.summary-edit').on('click',()=>summaryReturn('Users.editUser'));
$('.summary-back').on('click',()=>formSubmit({action:'Users.setupUsers'}));
$('.summary-add-note').on('click',()=>summaryReturn('Notes.addNote',{note_type:'User',note_reference:id}));
$('.summary-edit-note').on('click',function(){summaryReturn('Notes.editNote',{note_serial:$(this).data('note')});});
$('.summary-invite').on('click',async function(){
const button=$(this);button.prop('disabled',true);
try{const result=await formPost({action:'Users.resetPassword',user_serial:id});showRequestMessage(result.message||'Password setup link sent.');}catch(error){}finally{button.prop('disabled',false);}
});
});
+11 -40
View File
@@ -3,7 +3,7 @@
// View Journal
// ============
$(document).ready(function() {
$(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
@@ -12,7 +12,7 @@ $(document).ready(function() {
// Find Button
// -----------
$(".btnFind").on("click", function() {
$(".btnFind").on("click", async function () {
$sessionStorage.action = "Journal.viewJournal";
$sessionStorage.pagination = "first";
@@ -25,7 +25,7 @@ $(document).ready(function() {
});
$("#find_journal").on("keyup", function(e) {
$("#find_journal").on("keyup", async function (e) {
if (e.key === "Enter") {
$(".btnFind").trigger("click");
}
@@ -35,7 +35,7 @@ $(document).ready(function() {
// Clear Find Button
// -----------------
$(".btnClearFind").on("click", function() {
$(".btnClearFind").on("click", async function () {
$("#find_journal").val("").trigger("change");
@@ -54,7 +54,7 @@ $(document).ready(function() {
// Pagination Buttons
// ------------------
$(".btnPagination").on("click", function() {
$(".btnPagination").on("click", async function () {
$sessionStorage.action = "Journal.viewJournal";
$sessionStorage.pagination = $(this).data("pagination");
@@ -75,7 +75,7 @@ $(document).ready(function() {
// Done Button
// -----------
$(".btnDone").on("click", function() {
$(".btnDone").on("click", async function () {
formSubmit({ action : "" });
@@ -85,46 +85,17 @@ $(document).ready(function() {
// Refresh Button
// --------------
$(".btnRefresh").on("click", function() {
$(".btnRefresh").on("click", async function () {
window.location.reload();
});
// ---------------------
// Clear Journal Button.
// ---------------------
$(".btnClear").on("click", function() {
$("#confirm-dialog").removeClass().addClass("modal confirm-clear-journal")
.find(".modal-title").html("Confirm Clear Journal").end()
.find(".modal-body").html("Are you sure you wish to Clear the Journal?").end()
.modal("show");
});
$("body").on("click", ".confirm-clear-journal .btnConfirmDialogConfirm", function() {
$.post("./", { action : "Journal.clearJournal" })
.done(function() {
$sessionStorage.toast = "Journal was Cleared";
sessionStorage.setItem($application, JSON.stringify($sessionStorage));
window.location.reload();
});
});
// -------------------
// Save Changed Inputs
// -------------------
$(".autosave").on("change", function(e) {
$(".autosave").on("change", async function (e) {
switch (e.target.type) {
@@ -146,9 +117,9 @@ $(document).ready(function() {
// Initalize Inputs
// ----------------
$.each($sessionStorage, function(name, value) {
$.each($sessionStorage, function (name, value) {
var $input = $(":input").filter("[name='" + name + "']");
var $input = $(":input.autosave").not('[type="hidden"],[type="password"]').filter(function(){return this.name===name;});
if ($input.length > 0) {
@@ -178,7 +149,7 @@ $(document).ready(function() {
// Per Page Dropdown
// -----------------
$("#journals_per_page").on("change", function() {
$("#journals_per_page").on("change", async function () {
$(".btnFind").trigger("click");
+14 -4
View File
@@ -1,18 +1,28 @@
<?php
declare(strict_types=1);
ini_set('display_errors','0');
ini_set('log_errors','1');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('X-Frame-Options: DENY');
header('Cache-Control: no-store');
set_exception_handler(function (Throwable $e): void {
error_log((string) $e);
http_response_code(500);
echo 'The application could not complete this request. Please try again later.';
$status=$e instanceof AppException?$e->status:($e instanceof InvalidArgumentException?422:500);
$message=$status<500?$e->getMessage():'The application could not complete this request. Please try again later.';
if($e instanceof PDOException && $e->getCode()==='23000'){$status=409;$message='This record already exists or is still referenced by another record.';}
if($status>=500)error_log((string)$e);
http_response_code($status);
if(strtolower($_SERVER['HTTP_X_REQUESTED_WITH']??'')==='xmlhttprequest'){
header('Content-Type: application/json');echo json_encode(['ok'=>false,'error'=>$message]);
}else{header('Content-Type: text/html; charset=UTF-8');echo '<!doctype html><html lang="en"><title>Request could not be completed</title><main><h1>Request could not be completed</h1><p>'.htmlspecialchars($message,ENT_QUOTES,'UTF-8').'</p><p><a href="main.php">Return to the application</a></p></main></html>';}
});
require_once __DIR__ . '/classes/Base.php';
$application = new Base();
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$https = Transport::https($_SERVER,getenv('HCR_TRUSTED_PROXIES')?:'');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
if($https&&(string)$application->getSettings()->Environment==='Production')header('Strict-Transport-Security: max-age=31536000');
if ((string) $application->getSettings()->Environment === 'Production' && !$https) {
http_response_code(400);
exit('HTTPS is required. Open the secure application URL.');
+50
View File
@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS clubs (
club_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_name VARCHAR(128) NOT NULL,
club_status ENUM('active','archived') NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (club_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_memberships (
membership_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_serial BIGINT UNSIGNED NOT NULL,
user_serial BIGINT UNSIGNED NOT NULL,
membership_status ENUM('active','inactive') NOT NULL DEFAULT 'active',
joined_on DATE NOT NULL,
UNIQUE KEY (club_serial,user_serial),
UNIQUE KEY (membership_serial,club_serial),
FOREIGN KEY (club_serial) REFERENCES clubs(club_serial) ON DELETE RESTRICT,
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_seasons (
season_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_serial BIGINT UNSIGNED NOT NULL,
season_name VARCHAR(100) NOT NULL,
starts_on DATE NOT NULL,
ends_on DATE NOT NULL,
UNIQUE KEY (club_serial,season_name),
UNIQUE KEY (season_serial,club_serial),
FOREIGN KEY (club_serial) REFERENCES clubs(club_serial) ON DELETE RESTRICT,
CHECK (ends_on>=starts_on)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_dues (
due_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_serial BIGINT UNSIGNED NOT NULL,
membership_serial BIGINT UNSIGNED NOT NULL,
season_serial BIGINT UNSIGNED NOT NULL,
amount_cents BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (membership_serial,season_serial),
FOREIGN KEY (membership_serial,club_serial) REFERENCES club_memberships(membership_serial,club_serial) ON DELETE RESTRICT,
FOREIGN KEY (season_serial,club_serial) REFERENCES club_seasons(season_serial,club_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_payments (
payment_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
due_serial BIGINT UNSIGNED NOT NULL,
amount_cents BIGINT UNSIGNED NOT NULL,
paid_on DATE NOT NULL,
request_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (due_serial) REFERENCES club_dues(due_serial) ON DELETE RESTRICT,
CHECK (amount_cents>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+32
View File
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS email_verifications (
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
user_serial BIGINT UNSIGNED NOT NULL,
email VARCHAR(100) NOT NULL,
auth_version INT UNSIGNED NOT NULL,
expires_at DATETIME NOT NULL,
INDEX(user_serial), INDEX(expires_at),
FOREIGN KEY(user_serial) REFERENCES users(user_serial) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS club_due_adjustments (
adjustment_serial BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
due_serial BIGINT UNSIGNED NOT NULL,
amount_cents BIGINT NOT NULL,
corrected_amount_cents BIGINT UNSIGNED NOT NULL,
reason VARCHAR(500) NOT NULL,
recorded_by BIGINT UNSIGNED NOT NULL,
request_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(due_serial) REFERENCES club_dues(due_serial) ON DELETE RESTRICT,
FOREIGN KEY(recorded_by) REFERENCES users(user_serial) ON DELETE RESTRICT,
CHECK(amount_cents<>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS club_payment_reversals (
reversal_serial BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
payment_serial BIGINT UNSIGNED NOT NULL UNIQUE,
reason VARCHAR(500) NOT NULL,
recorded_by BIGINT UNSIGNED NOT NULL,
request_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(payment_serial) REFERENCES club_payments(payment_serial) ON DELETE RESTRICT,
FOREIGN KEY(recorded_by) REFERENCES users(user_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-86
View File
@@ -1,86 +0,0 @@
<?php
// ====================
// Export Permits - CSV
// ====================
class Export_AllPermitsByBuilderCSV extends Base {
// ==================
// Class Constructor.
// ==================
public function __construct() {
set_error_handler(array($this, "displayError"));
date_default_timezone_set(self::TIMEZONE);
}
// ===============
// Export the Data
// ===============
public function export() {
set_time_limit(0);
$subscription_serial = filter_input(INPUT_POST, "subscription_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$subscription = (new Subscriptions())->getSubscription($subscription_serial);
$permit_area = $subscription->record->subscription_area;
$projecttype = $subscription->record->subscription_projecttype;
$permits = (new PermitsByBuilder())->getAllPermits($permit_area, $projecttype);
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="AllPermitsByBuilder.csv"');
$output = fopen('php://output', 'w');
$header = array('Builder/Company', 'Permits', 'Value', 'Current Year YTD', 'Current YTD Value', 'Last 12 Months', 'Address', 'Phone');
// Add CSV headers
fputcsv($output, $header, ',', '"', '\\');
$permit_ytd_value = 0.00;
$permit_value = 0.00;
foreach ($permits as $permit) {
$builder = $permit->permit_company;
$permitcount = $permit->permit_permit_count;
$permit_value = $permit->permit_value_verbose;
$currentyearytd = $permit->ytd_permits;
$permit_ytd_value = $permit->ytd_value;
$permit12monthprevpermits = $permit->previous_ytd_permits;
$address = "{$permit->permit_address} {$permit->permit_city} {$permit->permit_state} {$permit->permit_zip}";
$phone = $permit->permit_phone_verbose;
$permit_ytd_value = "$" . number_format((int) $permit_ytd_value, 2);
$permit_value = "$" . number_format((int) $permit_value, 2);
$permit_data = array($builder, $permitcount, $permit_value, $currentyearytd, $permit_ytd_value, $permit12monthprevpermits, $address, $phone);
fputcsv($output, $permit_data, ',', '"', '\\');
}
fclose($output);
exit();
}
// ===============
// Debug an Object
// ===============
public function debug($object = "") {
echo "<pre>";
print_r($object);
exit();
}
}
?>
-144
View File
@@ -1,144 +0,0 @@
<?php
// ==================
// Export Permits
// ==================
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class Export_AllPermitsByBuilderExcel extends Base {
// ==================
// Class Constructor.
// ==================
public function __construct() {
set_error_handler(array($this, "displayError"));
date_default_timezone_set(self::TIMEZONE);
}
// ===============
// Export the Data
// ===============
public function export() {
set_time_limit(0);
$subscription_serial = filter_input(INPUT_POST, "subscription_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$subscription = (new Subscriptions())->getSubscription($subscription_serial);
$permit_area = $subscription->record->subscription_area;
$projecttype = $subscription->record->subscription_projecttype;
$permits = (new PermitsByBuilder())->getAllPermits($permit_area, $projecttype);
// Create the Spreadsheet.
$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()->setCreator("DEC International")
->setTitle("Permits")
->setSubject("Permits")
->setDescription("Permits")
->setKeywords("Permits")
->setCategory("Permits");
// Create the Base Sheet.
$spreadsheet->setActiveSheetIndex(0);
$spreadsheet->getActiveSheet()->setTitle("Permits");
// Format the Cells and Create the Headings.
$spreadsheet->getActiveSheet()->getColumnDimension("A")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("B")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("C")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("D")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("E")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("F")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("G")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getColumnDimension("H")->setAutoSize(true);
$spreadsheet->getActiveSheet()->getStyle("A1:H1")->getFont()->setBold(true);
$spreadsheet->getActiveSheet()->getStyle("A1:H1")->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB("D3D3D3");
$spreadsheet->setActiveSheetIndex(0)->setCellValue("A1", "Builder/Company")
->setCellValue("B1", "Permits")
->setCellValue("C1", "Value")
->setCellValue("D1", "Current Year YTD")
->setCellValue("E1", "Current Year YTD Value")
->setCellValue("F1", "Last 12 Months")
->setCellValue("G1", "Address")
->setCellValue("H1", "Phone");
$spreadsheet->getActiveSheet()->getStyle("A:A")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_NUMBER);
$spreadsheet->getActiveSheet()->getStyle("C:C")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->getStyle("D:D")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->getStyle("E:E")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->getStyle("F:F")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->getStyle("G:G")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->getStyle("H:H")->getNumberFormat()->setFormatCode(\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT);
$spreadsheet->getActiveSheet()->freezePane("A2");
// Create the Detail Rows.
$row = 2;
$permit_ytd_value = 0.00;
$permit_value = 0.00;
foreach ($permits as $permit) {
$address = "{$permit->permit_address} {$permit->permit_city}, {$permit->permit_state} {$permit->permit_zip}";
$permit_ytd_value = "$" . number_format((int) $permit->ytd_value, 2);
$permit_value = "$" . number_format((int) $permit->permit_value_verbose, 2);
$spreadsheet->getActiveSheet()->setCellValue("A" . $row, (string) $permit->permit_company);
$spreadsheet->getActiveSheet()->setCellValue("B" . $row, (string) $permit->permit_permit_count);
$spreadsheet->getActiveSheet()->setCellValue("C" . $row, (string) $permit_value);
$spreadsheet->getActiveSheet()->setCellValue("D" . $row, (string) $permit->ytd_permits);
$spreadsheet->getActiveSheet()->setCellValue("E" . $row, (string) $permit_ytd_value);
$spreadsheet->getActiveSheet()->setCellValue("F" . $row, (string) $permit->previous_ytd_permits);
$spreadsheet->getActiveSheet()->setCellValue("G" . $row, (string) $address);
$spreadsheet->getActiveSheet()->setCellValue("H" . $row, (string) $permit->permit_phone_verbose);
$row++;
}
$spreadsheet->setActiveSheetIndex(0);
// Download the spreadsheet.
header("Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
header("Content-disposition: attachment; filename=AllPermitsByBuilder.xlsx");
header("Cache-Control: max-age=0");
$excelWriter = new Xlsx($spreadsheet);
@ob_clean();
$excelWriter->save('php://output');
exit();
}
// ===============
// Debug an Object
// ===============
public function debug($object = "") {
echo "<pre>";
print_r($object);
exit();
}
}
?>
-256
View File
@@ -1,256 +0,0 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
// ==========================
// Print Selected Work Orders
// ==========================
class Print_All_PermitsByBuilder extends Base {
// Variables.
private $output_type = "normal";
// ==================
// Class Constructor.
// ==================
public function __construct($output_type = "normal") {
set_error_handler(array($this, "displayError"));
date_default_timezone_set(self::TIMEZONE);
$this->output_type = $output_type;
}
// ================
// Print the Report
// ================
public function print() {
$report = new AllPermitsByBuilder_Print();
$report->subscription_serial = $_SESSION[self::REPORTS_POST]["subscription_serial"] ?? 0;
$report->output_type = $this->output_type;
ob_clean();
if ($this->output_type == "string") {
return $report->render();
} else {
$report->render();
}
}
}
// ==========================
// Class to Generate the .pdf
// ==========================
class AllPermitsByBuilder_Print extends TCPDF {
// Variables
public $subscription_serial = 0;
public $first_page = true;
public $output_type = "normal";
// ================
// Debug an Object.
// ================
public function debug($object = "") {
echo "<pre>";
print_r($object);
exit();
}
// =================
// Render the Report
// =================
public function render() {
set_time_limit(0);
$subscription = (new Subscriptions())->getSubscription($this->subscription_serial);
$permit_area = $subscription->record->subscription_area;
$projecttype = $subscription->record->subscription_projecttype;
// Set report defaults
$this->title = "All Permit Records";
$this->SetFont("Calibri");
$this->SetMargins(10, 26, 10);
$this->SetHeaderMargin(10);
$this->SetFooterMargin(10);
$this->setCellPaddings(0, 0, 0, 0);
$this->setCellMargins(2, 0, 0, 0);
$this->addPage("L", "LETTER");
$rowCount = 0;
// Get the data for the report
$permits = (new PermitsByBuilder())->getAllPermits($permit_area, $projecttype);
// -------
// Permits
// -------
$permit_ytd_value = 0.00;
$permit_value = 0.00;
if ($permits->count() > 0) {
$this->Ln(7);
$this->SetFont("Calibri", "B", 10);
$this->Cell(52, 0, "", "", 0, "L", false, "", 3);
$this->Cell(10, 0, "", "", 0, "L", false, "", 3);
$this->Cell(20, 0, "", "", 0, "L", false, "", 3);
$this->Cell(15, 0, "Current", "", 0, "L", false, "", 3);
$this->Cell(20, 0, "Current Year", "", 0, "L", false, "", 3);
$this->Cell(15, 0, "Last", "", 0, "L", false, "", 3);
$this->Cell(85, 0, "", "", 0, "L", false, "", 3);
$this->Cell(25, 0, "", "", 0, "L", false, "", 3);
$this->Ln();
$this->Cell(52, 0, "Company/Builder", "B", 0, "L", false, "", 3);
$this->Cell(10, 0, "Permits", "B", 0, "L", false, "", 3);
$this->Cell(20, 0, "Value", "B", 0, "L", false, "", 3);
$this->Cell(15, 0, "Year YTD", "B", 0, "L", false, "", 3);
$this->Cell(20, 0, "YTD Value", "B", 0, "L", false, "", 3);
$this->Cell(15, 0, "12 Months", "B", 0, "L", false, "", 3);
$this->Cell(85, 0, "Address", "B", 0, "L", false, "", 3);
$this->Cell(25, 0, "Phone", "B", 0, "L", false, "", 3);
$this->SetFont("Calibri", "", 10);
$this->Ln(6);
foreach ($permits as $permit) {
$this->stripeLine($rowCount, 257);
$rowCount++;
$address = "{$permit->permit_address} {$permit->permit_city}, {$permit->permit_state} {$permit->permit_zip}";
$permit_ytd_value = "$" . number_format((int) $permit->ytd_value, 2);
$permit_value = "$" . number_format((int) $permit->permit_value_verbose, 2);
$this->Cell(52, 0, $permit->permit_company, 0, 0, "L", false, "", 3);
$this->Cell(10, 0, $permit->permit_permit_count, 0, 0, "C", false, "", 3);
$this->Cell(20, 0, $permit_value, 0, 0, "L", false, "", 3);
$this->Cell(15, 0, $permit->ytd_permits, 0, 0, "C", false, "", 3);
$this->Cell(20, 0, $permit_ytd_value, 0, 0, "L", false, "", 3);
$this->Cell(15, 0, $permit->previous_ytd_permits, 0, 0, "C", false, "", 3);
$this->Cell(85, 0, $address, 0, 0, "L", false, "", 3);
$this->Cell(25, 0, $permit->permit_phone_verbose, 0, 0, "L", false, "", 3);
$this->Ln(4);
if ($rowCount == 38) {
$this->AddPage("L", "LETTER");
$this->SetFont("Calibri", "B", 10);
$this->Cell(52, 0, "", "", 0, "L", false, "", 3);
$this->Cell(10, 0, "", "", 0, "L", false, "", 3);
$this->Cell(20, 0, "", "", 0, "L", false, "", 3);
$this->Cell(15, 0, "Current", "", 0, "L", false, "", 3);
$this->Cell(20, 0, "Current Year", "", 0, "L", false, "", 3);
$this->Cell(15, 0, "Last", "", 0, "L", false, "", 3);
$this->Cell(85, 0, "", "", 0, "L", false, "", 3);
$this->Cell(25, 0, "", "", 0, "L", false, "", 3);
$this->Ln();
$this->Cell(52, 0, "Company/Builder", "B", 0, "L", false, "", 3);
$this->Cell(10, 0, "Permits", "B", 0, "L", false, "", 3);
$this->Cell(20, 0, "Value", "B", 0, "L", false, "", 3);
$this->Cell(15, 0, "Year YTD", "B", 0, "L", false, "", 3);
$this->Cell(20, 0, "YTD Value", "B", 0, "L", false, "", 3);
$this->Cell(15, 0, "12 Months", "B", 0, "L", false, "", 3);
$this->Cell(85, 0, "Address", "B", 0, "L", false, "", 3);
$this->Cell(25, 0, "Phone", "B", 0, "L", false, "", 3);
$this->SetFont("Calibri", "", 10);
$this->Ln(6);
$rowCount = 0;
}
}
}
// --------------
// Return the pdf
// --------------
if ($this->output_type == "string") {
return $this->Output("AllPermitByBuilderRecords.pdf", "S");
} else {
$this->Output("AllPermitByBuilderRecords.pdf");
}
}
// ======
// Header
// ======
public function Header() {
$this->setCellPaddings(0, 0, 0, 0);
$this->setCellMargins(2, 0, 0, 0);
$this->Image("images/dec-international-logo.png", 10, 10, 25, 12);
$this->SetFont("Calibri", "B", 16);
$this->Cell(0, 6, "DEC International", 0, 0, "C");
$this->Ln(6);
$this->Cell(0, 6, "All Permit By Builder Records", 0, 1, "C");
}
// ======
// Footer
// ======
public function Footer() {
$this->SetFont("Calibri", "I", 8);
$X = $this->GetX();
$this->Cell(0, 10, "DEC International", 0, false, "C");
$this->SetX($X);
$this->Cell(40, 10, "Page " . $this->getAliasNumPage() . "/" . $this->getAliasNbPages(), 0, false, "L");
$this->Cell(0, 10, date("n/j/Y g:i A"), 0, false, "R");
}
// =======================
// Stripe the printed line
// =======================
private function stripeLine($pRowCount = 0, $pRowWidth = 0) {
$this->SetFillColor(($pRowCount % 2) ? 220 : 255);
$X = $this->GetX();
$this->Cell($pRowWidth, 0, "", 0, 0, "L", true);
$this->SetX($X);
}
}
?>
+1 -1
View File
@@ -8,7 +8,7 @@ $SQL = "create table journal
journal_timestamp timestamp default current_timestamp,
journal_origin varchar(100) default null,
journal_ip varchar(128) default null,
journal_entry varchar(1000) default null,
journal_entry text default null,
primary key (journal_serial),
index (journal_timestamp)
+1
View File
@@ -8,6 +8,7 @@ $SQL = "create table users (
user_first_name varchar(128) default null,
user_last_name varchar(128) default null,
user_email varchar(100) not null,
user_email_verified_at datetime default null,
user_password_hash varchar(255) not null,
user_role varchar(255) not null,
+62
View File
@@ -0,0 +1,62 @@
"""Disposable HTTP test application. Requires an explicitly supplied test database server."""
import http.cookiejar
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
ROOT=Path(__file__).resolve().parents[1]
PHP=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
class Application:
def __init__(self):
if not os.environ.get('HCR_TEST_DSN'):
raise RuntimeError('An isolated HCR_TEST_DSN is required.')
self.env=os.environ.copy()
self.env.update(HCR_APPLICATION_URL='',HCR_ENVIRONMENT='Development')
self.database=self.fixture('create')['database']
self.env['HCR_DB_NAME']=self.database
with socket.socket() as available:
available.bind(('127.0.0.1',0));port=available.getsockname()[1]
self.base=f'http://127.0.0.1:{port}'
self.log=tempfile.TemporaryFile(mode='w+b')
self.server=subprocess.Popen(PHP+['-S',f'127.0.0.1:{port}','bin/dev-router.php'],cwd=ROOT,env=self.env,stdout=self.log,stderr=self.log)
self.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
for _ in range(100):
try:self.request();break
except urllib.error.URLError:time.sleep(.1)
else:self.close();raise RuntimeError('Test HTTP server did not start.')
def fixture(self,action,*extra):
args=PHP+['tests/http_fixture.php',action]+([self.database] if hasattr(self,'database') else [])+list(extra)
result=subprocess.run(args,cwd=ROOT,env=self.env,capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
return json.loads(result.stdout.splitlines()[-1]) if result.stdout.strip() else {}
def request(self,path='/',data=None,ajax=False):
req=urllib.request.Request(self.base+path,data=urllib.parse.urlencode(data).encode() if data is not None else None,headers={'X-Requested-With':'XMLHttpRequest'} if ajax else {})
try:
with self.client.open(req,timeout=30) as response:return response.status,response.read(),response.headers
except urllib.error.HTTPError as error:return error.code,error.read(),error.headers
@staticmethod
def csrf(body):
match=re.search(rb'name="csrf(?:_token|-token)"[^>]*(?:value|content)="([a-f0-9]+)"',body)
if not match:raise AssertionError('Missing CSRF token: '+body[:500].decode(errors='replace'))
return match.group(1).decode()
def login(self,email='admin@example.test',password='Admin test password 123!'):
_,body,_=self.request()
status,body,_=self.request(data={'action':'SignIn.login','csrf_token':self.csrf(body),'user_email':email,'user_password':password})
if status!=200 or b'name="csrf-token"' not in body:raise AssertionError('Login failed: '+body[:500].decode(errors='replace'))
self.token=self.csrf(body)
def post(self,action,**values):
return self.request(data={'action':action,'csrf_token':self.token,**values},ajax=True)
def logout(self):self.post('signout')
def close(self):
self.server.terminate();self.server.wait(timeout=10)
self.fixture('drop')
self.log.seek(0);folder=ROOT/'tests/.local';folder.mkdir(exist_ok=True);(folder/'last-http.log').write_bytes(self.log.read());self.log.close()
+1 -1
View File
@@ -90,7 +90,7 @@ try:
check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
token = csrf(html)
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges')
check(request(data={'action': 'print', 'report': 'Email', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
check(request(data={'action': 'RosterReports.csv', 'club_serial': '1', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
status, html, _ = request(data={'action': 'signout', 'csrf_token': token})
+1
View File
@@ -51,6 +51,7 @@ try {
foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
if (trim($sql)) $server->exec($sql);
}
require __DIR__.'/../tables/journal.php';$server->exec($SQL);
require __DIR__ . '/../views/view_users.php';
$server->exec($SQL);
$auth = new TestAuth($server);
+54
View File
@@ -0,0 +1,54 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const path=require('node:path');
const fs=require('node:fs/promises');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const page=await browser.newPage({viewport:{width:1440,height:1000}});
const errors=[],checks=[];page.on('pageerror',error=>errors.push(error.message));
await page.exposeFunction('recordCspViolation',message=>errors.push(message));await page.addInitScript(()=>document.addEventListener('securitypolicyviolation',event=>window.recordCspViolation('CSP: '+event.violatedDirective+' '+event.blockedURI)));
const pass=message=>{checks.push(message);console.log('PASS: '+message);};
const submit=async data=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(data=>formSubmit(data),data)]);await page.waitForFunction(()=>typeof formSubmit==='function');await page.waitForLoadState('networkidle');};
try{
await page.goto(process.env.HCR_TEST_URL+'/main.php');
await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await page.waitForFunction(()=>typeof formSubmit==='function');
pass('Browser login loads the application shell without script errors');
const safe=await page.evaluate(()=>{
const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){};
try{const value='"><img id="injected-hidden-value" src=x>';formSubmit({action:'Users.setupUsers',find_user:value});return !document.querySelector('#injected-hidden-value')&&document.querySelector('#formSubmit input[name="find_user"]').value===value;}finally{HTMLFormElement.prototype.submit=original;}
});assert(safe);pass('Hidden form values remain text rather than injected HTML');
await submit({action:'Users.setupUsers'});await page.locator('.setupUsers').waitFor();
await page.locator('.btnAdd').click();await page.locator('#addUser').waitFor();
await page.locator('#user_name').fill('browser_tester');await page.locator('#user_first_name').fill('Browser');await page.locator('#user_last_name').fill('Tester');await page.locator('#user_email').fill('browser@example.test');await page.locator('#user_role').selectOption('User');
await page.locator('.btnSave').click();await page.locator('.setupUsers').waitFor();
await page.getByText('Browser',{exact:true}).waitFor();pass('Administrative account form saves through asynchronous validation');
assert((await page.locator('.toast-body').innerText()).includes('invitation'));pass('Failed invitation is visible after returning to the user list');
const row=page.locator('tr.user-row').filter({hasText:'browser_tester'});await row.click();await page.locator('.userSummary').waitFor();pass('Clicking a user opens the repaired summary');
await page.locator('.summary-edit').click();await page.locator('#editUser').waitFor();
const failUsage=async route=>{
const data=route.request().postData()||'';
if(data.includes('Users.userActive'))await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({ok:false,error:'Deliberate usage-check failure'})});else await route.continue();
};
await page.route('**/*',failUsage);await page.locator('.btnDelete').click();await page.getByText('Deliberate usage-check failure',{exact:true}).waitFor();assert(!await page.locator('#confirm-dialog').isVisible());pass('A failed deletion check shows an error and does not open confirmation');await page.unroute('**/*',failUsage);
await page.locator('.btnDelete').click();await page.locator('#confirm-dialog').waitFor({state:'visible'});await page.locator('#confirm-dialog .btnConfirmDialogConfirm').click();await page.locator('.setupUsers').waitFor();assert(await page.locator('tr.user-row').filter({hasText:'browser_tester'}).count()===0);pass('Deleting an unused account succeeds through the browser');
await submit({action:'Dropdowns.setupDropdowntypes'});await page.locator('.setupDropdowntypes').waitFor();await page.locator('.btnAdd').click();await page.locator('#addDropdowntype').waitFor();await page.locator('#dropdowntype_name').fill('browser_type');await page.locator('#dropdowntype_title').fill('Browser type');await page.locator('.btnSave').click();await page.locator('.setupDropdowntypes').waitFor();pass('Dropdown type form works with restricted usage metadata');
await submit({action:'Alerts.addAlert'});await page.locator('.ck-editor').waitFor();pass('Alert editor initializes with updated jQuery and the security policy');
await submit({action:'Popovers.addPopover'});await page.locator('.ck-editor').waitFor();pass('Popover editor initializes with updated jQuery and the security policy');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.workspace');
let form=page.locator('form').filter({has:page.getByRole('button',{name:'Create club',exact:true})});await form.getByLabel('Club name',{exact:true}).fill('Browser Club');await form.getByRole('button',{name:'Create club',exact:true}).click();await page.getByRole('heading',{name:'Browser Club',exact:true}).waitFor();pass('Club creation works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance');
await page.getByText('Correct dues',{exact:true}).click();
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save correction',exact:true})});await form.getByLabel('Corrected total (USD)').fill('8.00');await form.getByLabel('Reason',{exact:true}).fill('Correct season rate');await form.getByRole('button',{name:'Save correction',exact:true}).click();await page.getByRole('cell',{name:'$8.00',exact:true}).waitFor();pass('Dues correction updates the balance and preserves history in the browser');
await page.getByText('Reverse incorrect payment',{exact:true}).click();
form=page.locator('form').filter({has:page.getByRole('button',{name:'Confirm reversal',exact:true})});await form.getByLabel('Reversal reason').fill('Wrong receipt');await form.getByRole('button',{name:'Confirm reversal',exact:true}).click();await page.getByText('Reversed: Wrong receipt',{exact:false}).waitFor();pass('Payment reversal is recorded visibly without deleting the original payment');
const ledgerDownload=page.waitForEvent('download');await page.getByRole('button',{name:'Export financial history (CSV)',exact:true}).click();assert.equal((await ledgerDownload).suggestedFilename(),'club-financial-history.csv');pass('Financial history can be downloaded from the browser');
await page.screenshot({path:path.join(__dirname,'.local','roster.png'),fullPage:true});
await submit({action:'Reports.selectReport'});await page.getByRole('heading',{name:'Roster reports'}).waitFor();const downloadEvent=page.waitForEvent('download');await page.getByRole('button',{name:'Download CSV'}).click();const download=await downloadEvent;assert.equal(download.suggestedFilename(),'club-roster.csv');pass('Report catalog downloads the roster CSV in the browser');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=verifyemail');await page.getByRole('heading',{name:'Verify your email'}).waitFor();await page.getByLabel('Email',{exact:true}).fill('unknown@example.test');await page.getByRole('button',{name:'Send verification link'}).click();await page.getByText('If an active account needs verification',{exact:false}).waitFor();pass('Verification request page gives a neutral response in the browser');
assert.deepEqual(errors,[]);pass('No unexpected browser JavaScript errors occurred');console.log(checks.length+' browser checks passed.');
}catch(error){await page.screenshot({path:path.join(__dirname,'.local','browser-failure.png'),fullPage:true});console.error('PAGE ERRORS:',errors);throw error;}
finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+11
View File
@@ -0,0 +1,11 @@
import os
import subprocess
from application_harness import Application,ROOT
app=Application()
try:
env=os.environ.copy();env['HCR_TEST_URL']=app.base
node=env.get('NODE_TEST_COMMAND','node')
result=subprocess.run([node,'tests/browser.js'],cwd=ROOT,env=env)
if result.returncode:raise SystemExit(result.returncode)
finally:app.close()
+65
View File
@@ -0,0 +1,65 @@
import json
import secrets
import subprocess
from application_harness import Application,ROOT,PHP
app=Application();workers=[]
def race(operation,records,extra=[]):
group=[]
for record in records:
process=subprocess.Popen(PHP+['tests/maintenance_worker.php',operation,app.database,str(record)]+list(map(str,extra)),cwd=ROOT,env=app.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
workers.append(process);group.append(process)
for process in group:
if process.stdout.readline().strip()!='READY':raise AssertionError(process.stderr.read())
for process in group:process.stdin.write('go\n');process.stdin.flush()
results=[]
for process in group:
output,error=process.communicate(timeout=20)
if process.returncode:raise AssertionError(error+output)
results.append(json.loads(output))
return results
try:
app.login()
app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Concurrent',dropdowntype_title='Concurrent',dropdowntype_table='*none',dropdowntype_column='')
kind=app.fixture('inspect')['dropdowntypes'][0]['dropdowntype_serial']
ids=[]
for value in ['First','Second']:
app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value=value)
ident=app.fixture('inspect')['dropdowns'][-1]['dropdown_serial'];ids.append(ident);app.post('Dropdowns.updateDropdownActive',dropdown_serial=ident,dropdown_checked='true')
result=race('default',ids,[kind])
assert all(item['ok'] for item in result),result
assert sum(int(row['dropdown_default']) for row in app.fixture('inspect')['dropdowns'])==1
print('PASS: Concurrent dropdown default changes preserve one default.')
app.post('Roster.workspace',step='club',club_serial=0,club_name='Concurrent ledger')
club=app.fixture('inspect')['clubs'][0]['club_serial']
app.post('Roster.workspace',step='member',club_serial=club,identifier='testadmin',membership_status='active',joined_on='2026-01-01')
app.post('Roster.workspace',step='season',club_serial=club,season_name='Season',starts_on='2026-01-01',ends_on='2026-12-31')
data=app.fixture('inspect');membership=data['club_memberships'][0]['membership_serial'];season=data['club_seasons'][0]['season_serial']
app.post('Roster.workspace',step='charge',club_serial=club,membership_serial=membership,season_serial=season,amount='100.00')
due=app.fixture('inspect')['club_dues'][0]['due_serial']
result=race('payment',[due,due],[club,'80.00'])
assert sorted(row['ok'] for row in result)==[False,True],result
assert len(app.fixture('inspect')['club_payments'])==1
print('PASS: Concurrent payments cannot exceed the outstanding balance.')
payment=app.fixture('inspect')['club_payments'][0]['payment_serial']
result=race('reverse',[payment,payment],[club,secrets.token_hex(32)])
assert all(row['ok'] for row in result),result
assert len(app.fixture('inspect')['club_payment_reversals'])==1
print('PASS: Concurrent reversal retries create only one reversal.')
result=race('adjust',[due,due],[club,secrets.token_hex(32)])
assert all(row['ok'] for row in result),result
assert len(app.fixture('inspect')['club_due_adjustments'])==1
print('PASS: Concurrent dues correction retries create only one adjustment.')
response=app.post('Users.addUser',step='add',user_name='second_admin',user_first_name='Second',user_last_name='Administrator',user_email='second@example.test',user_role='Administrator')
assert response[0]==200,response[1]
ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='Administrator']
result=race('disable',ids)
assert sorted(item['ok'] for item in result)==[False,True],result
assert next(item for item in result if not item['ok'])['status']==409,result
assert sum(row['user_role']=='Administrator' and row['user_status']=='active' for row in app.fixture('inspect')['users'])==1
print('PASS: Concurrent administrator changes cannot disable the final administrator.')
print('5 concurrency scenarios passed.')
finally:
for process in workers:
if process.poll() is None:process.terminate();process.wait(timeout=10)
app.close()
+39
View File
@@ -0,0 +1,39 @@
import hashlib,json,os,shutil,subprocess,sys,tempfile,zipfile
from pathlib import Path
from application_harness import Application,PHP,ROOT
checks=[]
def check(value,message):
if not value:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
app=Application()
try:
before=app.fixture('fingerprints')
for _ in range(2):subprocess.run(PHP+['bin/migrate-production.php'],cwd=ROOT,env=app.env,check=True,capture_output=True)
check(app.fixture('fingerprints')==before,'Production migration can be rerun without changing existing rows')
result=subprocess.run(PHP+['bin/check-health.php'],cwd=ROOT,env=app.env,capture_output=True)
check(result.returncode==0,'CLI health check confirms database and required schema')
with tempfile.TemporaryDirectory(prefix='hcr-release-check-') as temporary:
output=Path(temporary)/'release.zip'
subprocess.run([sys.executable,'bin/build-release.py',str(output)],cwd=ROOT,check=True,capture_output=True)
with zipfile.ZipFile(output) as archive:
names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json'))
check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests')
check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery')
check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file')
binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump')
if not binary:
candidate=Path('C:/Users/JamesRichie/AppData/Local/Temp/hcr-auth-mariadb/mariadb-11.4.5-winx64/bin/mariadb-dump.exe')
if candidate.is_file():binary=str(candidate)
if not binary:raise RuntimeError('Install mariadb-dump to verify backup tooling.')
env=app.env.copy();env['HCR_DUMP_BINARY']=binary
result=subprocess.run(PHP+['bin/backup-database.php',temporary],cwd=ROOT,env=env,capture_output=True,text=True)
if result.returncode:raise RuntimeError(result.stdout+result.stderr)
metadata=json.loads(result.stdout);backup=Path(metadata['backup'])
check(backup.is_file() and hashlib.sha256(backup.read_bytes()).hexdigest()==metadata['sha256'],'Backup scheduler entry point produces a complete checksummed SQL backup')
check(not list(Path(temporary).glob('hcr-connection-*')),'Backup credentials file is removed after completion')
result=subprocess.run(PHP+['bin/backup-database.php',str(ROOT/'.tools')],cwd=ROOT,env=env,capture_output=True)
check(result.returncode!=0,'Backup tool rejects storage within the application tree')
for path in ['/.tools/backups/probe.sql','/migrations/003_production.sql','/bin/check-health.php','/xml/settings.xml','/release-manifest.json']:
check(app.request(path)[0] in [403,404],'Development router denies '+path)
finally:app.close()
print(str(len(checks))+' deployment tooling checks passed.')
+32
View File
@@ -12,14 +12,46 @@ if ($action === 'create') {
$db->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$db->exec("USE `{$name}`");
require __DIR__ . '/../tables/users.php'; $db->exec($SQL);
foreach(['dropdowntypes','dropdowns','notes','statuses','reports','alerts','popovers','journal','zipcodes'] as $table){require __DIR__.'/../tables/'.$table.'.php';$db->exec($SQL);}
foreach(glob(__DIR__.'/../views/view_*.php') as $view){require $view;$db->exec($SQL);}
$stmt=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role) VALUES('testadmin','Test','Administrator','admin@example.test',?,'Administrator')");$stmt->execute([password_hash('Admin test password 123!',PASSWORD_DEFAULT)]);
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/002_roster.sql')) as $sql){if(trim($sql))$db->exec($sql);}
$db->exec('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP()');
// The CLI migration must also support being run against a fresh users table.
putenv('HCR_DB_NAME=' . $name);
require __DIR__ . '/../bin/migrate-auth.php';
require __DIR__.'/../bin/migrate-production.php';
// Idempotent migration is verified separately by the HTTP test runner.
echo json_encode(['database' => $name]);
exit;
}
$db->exec("USE `{$name}`");
if($action==='fingerprints'){
$result=[];foreach($db->query('SHOW FULL TABLES WHERE Table_type="BASE TABLE"')->fetchAll(PDO::FETCH_COLUMN) as $table){
$rows=array_map(static fn($row)=>json_encode($row,JSON_THROW_ON_ERROR),$db->query('SELECT * FROM `'.str_replace('`','``',$table).'`')->fetchAll(PDO::FETCH_ASSOC));sort($rows,SORT_STRING);$result[$table]=hash('sha256',implode("\n",$rows));
}echo json_encode($result);exit;
}
if($action==='verifyEmail'){
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_name=?');$stmt->execute([$argv[3]??'']);echo json_encode(['verified'=>true]);exit;
}
if($action==='inspect'){
$tables=['users','dropdowntypes','dropdowns','statuses','notes','journal','clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals'];$result=[];
foreach($tables as $table)$result[$table]=$db->query('SELECT * FROM '.$table)->fetchAll(PDO::FETCH_ASSOC);
foreach($result['users'] as &$user)unset($user['user_password_hash']);unset($user);
echo json_encode($result);exit;
}
if($action==='unsafeRule'){
$db->exec("INSERT INTO dropdowntypes(dropdowntype_name,dropdowntype_title,dropdowntype_table,dropdowntype_column) VALUES('Unsafe legacy','Unsafe legacy','*function','getSettings')");$type=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO dropdowns(dropdown_dropdowntype,dropdown_value) VALUES(?,'Legacy')");$stmt->execute([$type]);echo json_encode(['dropdown'=>(int)$db->lastInsertId()]);exit;
}
if($action==='paging'){
$tip=$db->prepare('INSERT INTO popovers(popover_name,popover_title,popover_text) VALUES(?,?,?)');
$user=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role) VALUES(?,?,?,?,?,'User')");
$hash=password_hash('Paging test password!',PASSWORD_DEFAULT);
for($i=1;$i<=41;$i++){$tip->execute(['Paging '.sprintf('%02d',$i),'Paging '.$i,'Text']);$user->execute(['paging'.$i,'Paging','Family'.$i,'paging'.$i.'@example.test',$hash]);}
echo json_encode(['added'=>41]);exit;
}
if ($action === 'revoke') {
$db->exec("UPDATE users SET user_auth_version = user_auth_version + 1 WHERE user_name = 'httpuser'");
exit;
+25
View File
@@ -0,0 +1,25 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$checks=0;
function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException($message);$checks++;}
function invalid(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}verify(false,$message);}
foreach([0=>0,1=>0,20=>0,21=>20,41=>40] as $count=>$offset){$p=Pagination::calculate($count,20,1,'last');verify($p['offset']===$offset,'Last page offset for '.$count);verify($p['start']===($count?$offset+1:0),'Start count for '.$count);}
verify(Pagination::calculate(41,20,99)['page']===3,'Page is clamped after records are removed');
verify(Pagination::calculate(0,20,1,'next')['offset']===0,'Empty next page cannot have a negative offset');
verify(Pagination::calculate(200,-1,1)['size']===1,'Negative page size is clamped');
verify(Pagination::calculate(200,99999,1)['size']===100,'Large page size is bounded');
$_SESSION=[];Pagination::session('users',100,20,'last');$p=Pagination::session('notes',100,20);verify($p['page']===1,'Pagination does not leak between screens');
foreach(['0','-1','1-2','1 OR 1=1',[],null] as $id)invalid(fn()=>MaintenanceService::id($id),'Invalid record ID is rejected');
verify(MaintenanceService::id('12')===12,'Positive ID accepted');
foreach(['false'=>false,'true'=>true,'0'=>false,'1'=>true] as $input=>$expected)verify(MaintenanceService::boolean($input)===$expected,'Boolean is parsed strictly');
foreach(['false; DROP TABLE users','1 OR SLEEP(1)','',[],null] as $input)invalid(fn()=>MaintenanceService::boolean($input),'Nonboolean input rejected');
invalid(fn()=>MaintenanceService::usageRule('*function','getSettings'),'Inherited callbacks are not allowed');
invalid(fn()=>MaintenanceService::usageRule('users','user_password_hash'),'Database identifiers are not accepted as usage rules');
MaintenanceService::usageRule('*none','');verify(true,'Code-owned no-reference policy accepted');
foreach(['0'=>0,'1'=>100,'3.14'=>314,'10.1'=>1010,'9999999.99'=>999999999] as $input=>$expected)verify(RosterService::cents((string)$input)===$expected,'Currency parsed without floating point');
foreach(['-1','1.001','1e2','NaN','01','10000000'] as $input)invalid(fn()=>RosterService::cents($input),'Invalid currency rejected');
invalid(fn()=>RosterService::date('2026-02-30'),'Invalid calendar date rejected');
foreach(['=SUM(A1:A2)',' +CMD','@formula',"\tformula",'-123'] as $cell)verify(str_starts_with(RosterReports::csvText($cell),"'"),'CSV formula prefix neutralized');
verify(RosterReports::csvText('Plain member')==='Plain member','Ordinary CSV text preserved');
echo $checks." maintenance unit checks passed.\n";
+17
View File
@@ -0,0 +1,17 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();$name=$argv[2]??'';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name)||(string)$settings->DatabaseName!==$name||!in_array((string)$settings->DatabaseServer,['127.0.0.1','localhost','::1'],true))throw new RuntimeException('Concurrency workers require an isolated local test database.');
$_SESSION=[Base::USER_AUTHENTICATED=>true,Base::USER_ROLE=>'Administrator',Base::USER_SERIAL=>1,Base::USER_NAME=>'Test worker'];
echo "READY\n";flush();fgets(STDIN);
try{
$service=new MaintenanceService();
if(($argv[1]??'')==='disable')$service->setUserActive((int)$argv[3],false);
elseif(($argv[1]??'')==='default')$service->setDropdownDefault((int)$argv[3],(int)$argv[4],true);
elseif(($argv[1]??'')==='payment')(new LedgerService())->payment((int)$argv[4],(int)$argv[3],$argv[5],'2026-01-01',bin2hex(random_bytes(32)));
elseif(($argv[1]??'')==='reverse')(new LedgerService())->reverse((int)$argv[4],(int)$argv[3],'Concurrent reversal',$argv[5]);
elseif(($argv[1]??'')==='adjust')(new LedgerService())->adjust((int)$argv[4],(int)$argv[3],'90.00','Concurrent correction',$argv[5]);
else throw new RuntimeException('Unknown test operation.');
echo json_encode(['ok'=>true]),"\n";
}catch(AppException $e){echo json_encode(['ok'=>false,'status'=>$e->status]),"\n";}
+9
View File
@@ -0,0 +1,9 @@
import subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:
result=subprocess.run(PHP+['tests/production_security.php'],cwd=ROOT,env=app.env)
if result.returncode:raise SystemExit(result.returncode)
result=subprocess.run(PHP+['tests/runtime_privileges.php'],cwd=ROOT,env=app.env)
if result.returncode:raise SystemExit(result.returncode)
finally:app.close()
+91
View File
@@ -0,0 +1,91 @@
<?php
if(PHP_SAPI!=='cli')exit;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('A disposable test database is required.');
require __DIR__.'/../classes/Base.php';ob_start();session_start();
$db=(new Base())->connect();$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;
$checks=0;
function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException('FAILED: '.$message);$checks++;echo 'PASS: '.$message."\n";}
function rejects(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}throw new RuntimeException('FAILED: '.$message);}
class ReviewAuth extends AuthService {
public array $mail=[];public $onDelivery=null;public $onFind=null;
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
public function findUser(string $identifier):?array {$user=parent::findUser($identifier);if($this->onFind){$call=$this->onFind;$this->onFind=null;$call();}return $user;}
protected function deliverResetEmail(array $user,string $url):bool {$this->mail[]=$url;if($this->onDelivery){$call=$this->onDelivery;$this->onDelivery=null;$call();}return true;}
public function token():string {parse_str(parse_url(end($this->mail),PHP_URL_QUERY),$query);return $query['token'];}
}
class ReviewVerification extends EmailVerification {
public string $url='';public bool $mailWorks=true;
public function getSettings(){$settings=parent::getSettings();$settings->ApplicationUrl='https://example.test';return $settings;}
protected function deliver(array $user,string $url):bool {$this->url=$url;return $this->mailWorks;}
public function token():string {parse_str(parse_url($this->url,PHP_URL_QUERY),$query);return $query['token'];}
}
$auth=new ReviewAuth();$service=new MaintenanceService();$password='Production test password!';
$input=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_password'=>$password,'user_password_confirm'=>$password];$auth->register($input);
$id=(int)$auth->findUser('securitymember')['user_serial'];
$edit=['user_name'=>'securitymember','user_email'=>'security@example.test','user_first_name'=>'Security','user_last_name'=>'Member','user_role'=>'User','user_active'=>'true'];
$auth->requestReset('securitymember');$token=$auth->token();$edit['user_email']='changed@example.test';$service->updateUser($id,$edit);
verify(!$auth->resetPassword($token,$password,$password),'Old-email reset link is rejected after email change');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$service->setUserActive($id,false);$service->setUserActive($id,true);
verify(!$auth->resetPassword($token,$password,$password),'Disable/reactivate does not revive a reset link');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');$token=$auth->token();$edit['user_role']='Guest';$service->updateUser($id,$edit);
verify(!$auth->resetPassword($token,$password,$password),'Role change invalidates existing reset links');
$edit['user_role']='User';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');
$auth->onFind=function()use($service,$id,&$edit){$edit['user_email']='raced@example.test';$service->updateUser($id,$edit);};
verify(!$auth->requestReset('securitymember'),'Reset issuance rejects a user snapshot superseded by an account edit');
verify((int)$db->query('SELECT COUNT(*) FROM password_resets')->fetchColumn()===0,'Stale reset issuance leaves no usable token');
$auth->onDelivery=function()use($service,$id,&$edit){$edit['user_email']='later@example.test';$service->updateUser($id,$edit);};$auth->requestReset('securitymember');
verify(!$auth->resetPassword($auth->token(),$password,$password),'Email change during delivery invalidates an already-created reset token');
$verification=new ReviewVerification();$db->exec('DELETE FROM auth_attempts');
verify($verification->request('securitymember'),'Verification link is generated for an unverified account');
$token=$verification->token();verify(!str_contains(json_encode($db->query('SELECT * FROM email_verifications')->fetchAll()),$token),'Verification token is stored only as a hash');
verify(!$verification->confirm($token,'wrong password'),'Mailbox verification also requires the account password');
verify($verification->confirm($token,$password),'Correct verification link and password verify email ownership');
verify(!$verification->confirm($token,$password),'Verification links cannot be reused');
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Verified ownership is stored');
$edit['user_email']='unverified@example.test';$service->updateUser($id,$edit);
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing email clears ownership verification');
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$db->exec('UPDATE email_verifications SET expires_at=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 SECOND)');
verify(!$verification->confirm($token,$password),'Expired email verification link is rejected');
$db->exec('DELETE FROM auth_attempts');$verification->request('securitymember');$token=$verification->token();$edit['user_email']='newmail@example.test';$service->updateUser($id,$edit);
verify(!$verification->confirm($token,$password),'Email edit invalidates old verification links');
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=false;try{$verification->request('securitymember');throw new LogicException('Expected mail failure');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'could not be delivered'),'Verification mail failure is detected');}
verify((int)$db->query('SELECT COUNT(*) FROM email_verifications')->fetchColumn()===0,'Undelivered verification token is removed');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');verify($auth->resetPassword($auth->token(),$password,$password),'Password setup/reset can also prove mailbox ownership');
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Mailbox ownership is verified after successful password reset');
$edit['user_email']='stillunverified@example.test';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');ob_start();$auth->login('securitymember',$password);$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;$_SESSION['auth_version']=(int)$auth->findUser('securitymember')['user_auth_version'];$auth->changePassword($id,$password,$password,$password);ob_end_clean();
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing a known password does not falsely verify mailbox ownership');
$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;
$roster=new RosterService();$club=$roster->saveClub(0,'Security Club');
rejects(fn()=>$roster->saveMember($club,0,'securitymember','active','2026-01-01'),'Unverified email cannot receive active club membership');
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=true;$verification->request('securitymember');$verification->confirm($verification->token(),$password);
$roster->saveMember($club,0,'securitymember','active','2026-01-01');$membership=(int)$db->query('SELECT membership_serial FROM club_memberships')->fetchColumn();$roster->addSeason($club,'Season','2026-01-01','2026-12-31');$season=(int)$db->query('SELECT season_serial FROM club_seasons')->fetchColumn();$roster->charge($club,$membership,$season,'100.00');$due=(int)$db->query('SELECT due_serial FROM club_dues')->fetchColumn();$ledger=new LedgerService();
$key=bin2hex(random_bytes(32));$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');$ledger->payment($club,$due,'30.00','2026-01-02',$key,'check','=formula');
verify((int)$db->query('SELECT COUNT(*) FROM club_payments')->fetchColumn()===1,'Payment retries preserve exactly one original payment');
$key=bin2hex(random_bytes(32));$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);$ledger->adjust($club,$due,'80.00','Corrected season rate',$key);
$row=$roster->dues($club)[0];verify((int)$row['original_amount_cents']===10000&&(int)$row['amount_cents']===8000&&(int)$row['paid_cents']===3000,'Dues corrections preserve original amount and calculate effective balance');
verify((int)$db->query('SELECT COUNT(*) FROM club_due_adjustments')->fetchColumn()===1,'Dues correction retries are idempotent');
rejects(fn()=>$ledger->adjust($club,$due,'20.00','Too low',bin2hex(random_bytes(32))),'Dues cannot be corrected below received payments');
$payment=(int)$db->query('SELECT payment_serial FROM club_payments')->fetchColumn();$key=bin2hex(random_bytes(32));$ledger->reverse($club,$payment,'Incorrect check recorded',$key);$ledger->reverse($club,$payment,'Incorrect check recorded',$key);
verify((int)$db->query('SELECT COUNT(*) FROM club_payment_reversals')->fetchColumn()===1,'Payment reversal retries are idempotent');
$row=$roster->dues($club)[0];verify((int)$row['paid_cents']===0&&(int)$db->query('SELECT amount_cents FROM club_payments')->fetchColumn()===3000,'Reversal restores balance and preserves the original payment');
rejects(fn()=>$ledger->reverse($club,$payment,'Again',bin2hex(random_bytes(32))),'A payment cannot be reversed twice');
rejects(fn()=>$ledger->adjust($club,$due,'90.00','',bin2hex(random_bytes(32))),'Financial corrections require a reason');
$ledger->payment($club,$due,'80.00','2026-01-02',bin2hex(random_bytes(32)),'transfer','replacement');
verify((int)$roster->dues($club)[0]['paid_cents']===8000,'Replacement payment settles corrected dues');
rejects(fn()=>$ledger->payment($club,$due,'0.01','2026-01-02',bin2hex(random_bytes(32))),'Corrected paid dues still reject overpayment');
$history=$ledger->history($club);verify(count($history['payments'])===2&&count($history['adjustments'])===1,'History includes original payments, reversal details, and correction');
verify(str_contains((string)$db->query("SELECT journal_entry FROM journal WHERE journal_entry LIKE '%received payment%' LIMIT 1")->fetchColumn(),'amount_cents'),'Financial audit includes the amount and payment identifier');
verify((int)$db->query("SELECT COUNT(*) FROM journal WHERE journal_entry LIKE '%saved membership%' OR journal_entry LIKE '%created season%'")->fetchColumn()===2,'Membership and season writes have audit events');
rejects(fn()=>(new Journal())->clearJournal(),'Application journal clearing is prohibited');
$db->exec("CREATE TRIGGER reject_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Deliberate audit failure'");
try{$ledger->adjust($club,$due,'90.00','Audit should fail',bin2hex(random_bytes(32)));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify((int)$roster->dues($club)[0]['amount_cents']===8000,'Financial write rolls back if its mandatory audit fails');}
$before=$auth->findUser('securitymember')['user_first_name'];try{$service->updateUser($id,array_replace($edit,['user_first_name'=>'Should rollback']));throw new LogicException('Expected audit failure');}catch(PDOException $e){verify($auth->findUser('securitymember')['user_first_name']===$before,'Account edit rolls back if its audit fails');}
$db->exec('DROP TRIGGER reject_audit');
verify(!Transport::https(['REMOTE_ADDR'=>'203.0.113.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Untrusted forwarding headers cannot mark a request secure');
verify(Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https'],'127.0.0.1'),'Explicitly trusted proxy can report HTTPS');
verify(!Transport::https(['REMOTE_ADDR'=>'127.0.0.1','HTTP_X_FORWARDED_PROTO'=>'https,http'],'127.0.0.1'),'Ambiguous forwarded protocol is rejected');
verify(Transport::https(['HTTPS'=>'on']),'Direct HTTPS remains supported');
putenv('HCR_ENVIRONMENT=Production');putenv('HCR_DB_HOST=remote.example.test');putenv('HCR_DB_TLS_CA');
try{(new Base())->connect();throw new LogicException('Expected transport rejection');}catch(RuntimeException $e){verify(str_contains($e->getMessage(),'verified TLS'),'Remote production database connections fail closed without a trusted CA');}
echo "$checks production security and ledger checks passed.\n";
ob_end_flush();
+167
View File
@@ -0,0 +1,167 @@
import io
import json
import secrets
import zipfile
from xml.etree import ElementTree
from application_harness import Application
checks=[]
def check(condition,message,details=b''):
if not condition:raise AssertionError(message+': '+details[:700].decode(errors='replace'))
checks.append(message)
def expect(response,status,message):
check(response[0]==status,message,response[1]);return response[1]
app=Application()
try:
app.login()
csrf=app.token
expect(app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Unsafe',dropdowntype_title='Unsafe',dropdowntype_table='*function',dropdowntype_column='getSettings'),422,'Callback metadata is rejected on write')
expect(app.post('Dropdowns.addDropdowntype',step='add',dropdowntype_name='Colors',dropdowntype_title='Colors',dropdowntype_table='*none',dropdowntype_column=''),200,'Safe dropdown type can be created')
kind=app.fixture('inspect')['dropdowntypes'][0]['dropdowntype_serial']
expect(app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value='Green'),200,'Dropdown value can be created')
first=app.fixture('inspect')['dropdowns'][0]['dropdown_serial']
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='0, dropdown_value=123'),422,'SQL payload is rejected as an invalid boolean')
check(app.fixture('inspect')['dropdowns'][0]['dropdown_value']=='Green','Rejected SQL input leaves data unchanged')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='true'),409,'Inactive dropdown cannot become default')
expect(app.post('Dropdowns.updateDropdownActive',dropdown_serial=first,dropdown_checked='true'),200,'Dropdown can be activated')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=kind,dropdown_checked='true'),200,'Active dropdown can become default')
expect(app.post('Dropdowns.deleteDropdown',dropdown_serial=first),409,'Default dropdown cannot be deleted')
expect(app.post('Dropdowns.deleteDropdowntype',dropdowntype_serial=kind),409,'Type with child dropdowns cannot be deleted')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=first,dropdown_dropdowntype=99999,dropdown_checked='true'),404,'Wrong dropdown type is rejected')
expect(app.post('Dropdowns.addDropdown',step='add',dropdowntype_serial=kind,dropdown_value='Blue'),200,'Second dropdown can be created')
second=app.fixture('inspect')['dropdowns'][-1]['dropdown_serial']
app.post('Dropdowns.updateDropdownActive',dropdown_serial=second,dropdown_checked='true')
expect(app.post('Dropdowns.updateDropdownDefault',dropdown_serial=second,dropdown_dropdowntype=kind,dropdown_checked='true'),200,'Default can be switched atomically')
check(sum(int(row['dropdown_default']) for row in app.fixture('inspect')['dropdowns'])==1,'Only one dropdown default remains')
expect(app.post('Dropdowns.deleteDropdown',dropdown_serial=first),200,'Unused nondefault dropdown can be deleted')
unsafe=app.fixture('unsafeRule')['dropdown']
body=expect(app.post('Dropdowns.dropdownActive',dropdown_serial=unsafe),422,'Legacy callback also fails closed when already stored')
check(b'DatabasePassword' not in body and b'SmtpPassword' not in body,'Callback failure does not return settings')
for name in ['Ready','Waiting']:
expect(app.post('Statuses.addStatus',step='add',status_name=name,status_classes='text-primary',status_active='true',status_default='true'),200,'Status '+name+' can be saved')
state=app.fixture('inspect')
check(sum(int(row['status_default']) for row in state['statuses'])==1,'Only one default status remains')
default=next(row['status_serial'] for row in state['statuses'] if row['status_default'])
expect(app.post('Statuses.deleteStatus',status_serial=default),409,'Default status cannot be deleted')
expect(app.post('Statuses.statusActive',status_serial=default),200,'Status usage check no longer queries workorders')
expect(app.post('Statuses.addStatus',step='add',status_name='Broken',status_active='false',status_default='true'),422,'Inactive default status is rejected')
admin=state['users'][0]
expect(app.post('Users.deleteUser',user_serial=admin['user_serial']),409,'Administrator cannot delete own account')
expect(app.post('Users.updateActiveStatus',user_serial=admin['user_serial'],user_active='false'),409,'Last active administrator cannot be disabled')
expect(app.post('Users.editUser',step='update',user_serial=admin['user_serial'],user_name=admin['user_name'],user_first_name='Test',user_last_name='Administrator',user_email=admin['user_email'],user_role='User',user_active='true'),409,'Last administrator cannot be demoted')
details={'step':'add','user_name':'member_one','user_first_name':'First','user_last_name':'Member','user_email':'member@example.test','user_role':'User'}
body=expect(app.post('Users.addUser',**details),200,'Account creation survives invitation delivery failure')
check(bool(json.loads(body)['warning']),'Invitation failure has an explicit warning and resend path')
expect(app.post('Users.addUser',**details),409,'Retry cannot silently create a duplicate account')
member=next(row for row in app.fixture('inspect')['users'] if row['user_name']=='member_one')
expect(app.post('Notes.addNote',step='add',note_type='Other',note_reference=member['user_serial'],note_text='bad'),422,'Note type is validated during save')
expect(app.post('Notes.addNote',step='add',note_type='User',note_reference=999999,note_text='orphan'),409,'Orphan user note is rejected')
expect(app.post('Notes.addNote',step='add',note_type='User',note_reference=member['user_serial'],note_text='<script>text only</script>'),200,'Valid user note is saved')
expect(app.post('Users.deleteUser',user_serial=member['user_serial']),409,'User with note history cannot be deleted')
body=expect(app.request(data={'action':'Users.userSummary','user_serial':member['user_serial'],'csrf_token':csrf}),200,'User summary no longer depends on subscriptions')
check(b'&lt;script&gt;text only&lt;/script&gt;' in body,'Notes are escaped in the summary')
expect(app.post('Users.userActive',user_serial=member['user_serial']),200,'User usage check no longer queries requests')
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='North Woods'),200,'First club can be created')
club_a=app.fixture('inspect')['clubs'][0]['club_serial']
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='South Woods'),200,'Second club can be created')
club_b=app.fixture('inspect')['clubs'][-1]['club_serial']
for club in [club_a,club_b]:
expect(app.post('Roster.workspace',step='season',club_serial=club,season_name='2026 Season',starts_on='2026-01-01',ends_on='2026-12-31'),200,'Club season can be created')
expect(app.post('Roster.workspace',step='season',club_serial=club_a,season_name='Invalid',starts_on='2026-12-31',ends_on='2026-01-01'),422,'Reversed season dates are rejected')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Unverified accounts cannot receive active memberships')
app.fixture('verifyEmail','member_one')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),200,'Account can be assigned to a club')
expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Duplicate club membership is rejected')
state=app.fixture('inspect');membership=state['club_memberships'][0]['membership_serial'];season_a=state['club_seasons'][0]['season_serial'];season_b=state['club_seasons'][1]['season_serial']
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_b,amount='100.00'),409,'Cross-club season cannot be used for dues')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.001'),422,'Money rejects fractional cents')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),200,'Season dues can be recorded')
expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),409,'Duplicate season dues are rejected')
due=app.fixture('inspect')['club_dues'][0]['due_serial'];key=secrets.token_hex(32)
payment=dict(step='payment',club_serial=club_a,due_serial=due,amount='3.14',paid_on='2026-01-02',request_key=key)
expect(app.post('Roster.workspace',**payment),200,'Received payment can be recorded')
expect(app.post('Roster.workspace',**payment),200,'Repeated payment submission is idempotent')
check(len(app.fixture('inspect')['club_payments'])==1,'Payment retry does not double-charge the ledger')
expect(app.post('Roster.workspace',**{**payment,'amount':'100.00','request_key':secrets.token_hex(32)}),409,'Payment above balance is rejected')
for action,signature in [('csv',b'Club,Username'),('excel',b'PK'),('pdf',b'%PDF')]:
body=expect(app.request(data={'action':'RosterReports.'+action,'club_serial':club_a,'csrf_token':csrf}),200,action+' report downloads successfully')
check(body.startswith(signature),action+' report has the correct file format')
if action=='excel':
with zipfile.ZipFile(io.BytesIO(body)) as archive:
root=ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml'))
check(not root.findall('.//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}f'),'Spreadsheet cells do not contain formulas')
body=expect(app.post('Users.editUser',step='update',user_serial=member['user_serial'],user_name='member_one',user_first_name='=HYPERLINK("unsafe")',user_last_name='Updated',user_email='member@example.test',user_role='User',user_active='true'),200,'Administrator can edit first and last names')
body=app.request(data={'action':'RosterReports.csv','club_serial':club_a,'csrf_token':csrf})[1]
check(b"'=HYPERLINK" in body,'CSV formula text is neutralized')
# Set a known member password in this disposable fixture via the real reset flow.
# Fixture token generation is intentionally scoped to httpuser, so create that
# account through signup and assign it separately for isolation tests.
app.logout();_,signup,_=app.request('/?Xaction=signup')
expect(app.request(data={'action':'SignUp.createAccount','csrf_token':app.csrf(signup),'user_first_name':'Regular','user_last_name':'Member','user_email':'http@example.test','user_name':'httpuser','user_password':'HTTP long password!','user_password_confirm':'HTTP long password!'}),200,'Regular member account can sign up')
app.login();csrf=app.token
app.fixture('verifyEmail','httpuser')
app.post('Roster.workspace',step='member',club_serial=club_a,identifier='httpuser',membership_status='active',joined_on='2026-01-01')
app.logout();app.login('http@example.test','HTTP long password!')
body=expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),200,'Member can view assigned club')
check(b'South Woods' not in body,'Other clubs are absent from member navigation')
check(b'$100.00' not in body,'Member cannot view another member dues')
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_b)),403,'Member cannot access another club by changing its ID')
expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='Unauthorized'),403,'Member cannot create clubs')
expect(app.post('RosterReports.csv',club_serial=club_a),403,'Member cannot download administrator exports')
expect(app.post('Users.deleteUser',user_serial=member['user_serial']),403,'Member cannot delete accounts')
# Keep that member session alive while a separate administrator session changes
# membership/club status; access must be rechecked on each request.
member_client=app.client
import urllib.request,http.cookiejar
app.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
app.login();csrf=app.token;admin_client=app.client
http_membership=next(row['membership_serial'] for row in app.fixture('inspect')['club_memberships'] if row['user_serial']==next(user['user_serial'] for user in app.fixture('inspect')['users'] if user['user_name']=='httpuser'))
expect(app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='inactive',joined_on='2026-01-01'),200,'Membership can be made inactive while preserving history')
app.client=member_client
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Inactive membership immediately loses club access in an existing session')
app.client=admin_client
app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='active',joined_on='2026-01-01')
expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='archived'),200,'Club can be archived without deleting its history')
app.client=member_client
expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Archived club is inaccessible to ordinary members')
app.client=admin_client
expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='active'),200,'Archived club can be reactivated')
check(len(app.fixture('inspect')['club_payments'])==1,'Archiving and reactivation preserve the payment ledger')
body=expect(app.request(data={'action':'Journal.viewJournal','csrf_token':csrf}),200,'Journal page loads after authentication events')
check(b'logged in' in body,'Authentication writes journal entries')
for action in ['Users.setupUsers','Dropdowns.setupDropdowntypes','Statuses.setupStatuses','Alerts.setupAlerts','Popovers.setupPopovers','Journal.viewJournal']:
expect(app.request(data={'action':action,'pagination':'last','csrf_token':csrf}),200,action+' handles last page without SQL errors')
body=expect(app.post('getZipcode',zip_code='12345'),200,'ZIP lookup uses a valid route and JSON response')
json.loads(body)
app.fixture('paging')
from lxml import html as parser
import re
response=app.request(data={'action':'Popovers.setupPopovers','pagination':'last','csrf_token':csrf})
body=expect(response,200,'Last-page query works with 41 real records')
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 41 to 41 of 41' in text,'Last page starts at record 41 rather than record 22')
body=app.request(data={'action':'Users.setupUsers','csrf_token':csrf})[1]
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 1 to 20 of 44' in text,'User pagination is independent of popover pagination')
body=app.request(data={'action':'Users.setupUsers','find_user':'Family41','csrf_token':csrf})[1]
check(b'Family41' in body and b'paging40@example.test' not in body,'Users can be searched by last name')
body=app.request(data={'action':'Popovers.setupPopovers','pagination':'first','popovers_per_page':'40','csrf_token':csrf})[1]
text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
check('Showing 1 to 40 of 41' in text,'Per-page selection is respected')
expect(app.post('Journal.clearJournal'),404,'Journal-clearing endpoint is no longer routable')
expect(app.post('Roster.workspace',step='adjust',club_serial=club_a,due_serial=due,corrected_amount='90.00',reason='HTTP correction',request_key=secrets.token_hex(32)),200,'Dues corrections work through the protected HTTP route')
expect(app.post('Roster.workspace',step='payment',club_serial=club_a,due_serial=due,amount='1.00',paid_on='2026-01-02',request_key=secrets.token_hex(32),payment_method='check',payment_reference='=SUM(1,1)'),200,'Payment references and methods are saved through HTTP')
new_payment=app.fixture('inspect')['club_payments'][-1]['payment_serial']
expect(app.post('Roster.workspace',step='reverse',club_serial=club_a,payment_serial=new_payment,reason='HTTP reversal',request_key=secrets.token_hex(32)),200,'Payment reversals work through the protected HTTP route')
body=expect(app.request(data={'action':'RosterReports.ledger','club_serial':club_a,'csrf_token':csrf}),200,'Financial history exports through HTTP')
check(b"'=SUM(1,1)" in body,'Financial CSV neutralizes formula text in payment references')
check(b'HTTP correction' in body and b'HTTP reversal' in body,'Financial CSV includes correction and reversal reasons')
body=expect(app.request('/?Xaction=verifyemail'),200,'Public email verification page renders')
expect(app.request(data={'action':'verifyemail','step':'confirm','token':'0'*64,'user_password':'wrong'}),403,'Email verification rejects a missing CSRF token')
headers=app.request()[2]
check("script-src 'self'" in headers['Content-Security-Policy'],'Application sends a CSP without inline-script permission')
print('\n'.join('PASS: '+message for message in checks))
print(f'{len(checks)} project HTTP checks passed.')
finally:app.close()
+7
View File
@@ -0,0 +1,7 @@
import json,os,subprocess,sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.')
for suite in ['tests/source_checks.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/project_http.py','tests/production_runner.py','tests/concurrency.py','tests/browser_runner.py','tests/deployment_tools.py']:
subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
+17
View File
@@ -0,0 +1,17 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();$name=(string)$settings->DatabaseName;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name))throw new RuntimeException('Disposable database required.');
$db=(new Base())->connect();$account='hcr_check_'.bin2hex(random_bytes(6));$password=bin2hex(random_bytes(24));$identity=$db->quote($account)."@'%'";
$db->exec('CREATE USER '.$identity.' IDENTIFIED BY '.$db->quote($password));
try{
$sql=file_get_contents(__DIR__.'/../deploy/runtime-grants.sql.example');$sql=preg_replace('/^--.*$/m','',$sql);$sql=str_replace(['huntingclubroster',"'hcr_runtime'@'app-host.example'"],[$name,$identity],$sql);
foreach(explode(';',$sql) as $statement)if(trim($statement))$db->exec($statement);
$limited=new PDO('mysql:host='.$settings->DatabaseServer.';port='.(int)$settings->DatabasePort.';dbname='.$name,$account,$password,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
$limited->beginTransaction();Audit::required($limited,1,'runtime privilege verification');$limited->commit();$checks=1;
foreach(['DELETE FROM journal','UPDATE club_payments SET amount_cents=amount_cents','DELETE FROM club_dues','ALTER TABLE clubs ADD COLUMN forbidden INT'] as $statement){
try{$limited->exec($statement);throw new RuntimeException('A forbidden runtime operation was allowed.');}catch(PDOException $e){if(!in_array((int)($e->errorInfo[1]??0),[1142,1143],true))throw $e;$checks++;}
}
echo "$checks runtime privilege checks passed: audit inserts work; history deletion, payment edits, and schema changes are denied.\n";
}finally{$db->exec('DROP USER '.$identity);}

Some files were not shown because too many files have changed in this diff Show More