generated from jric11/baseProject
Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled
Security and workflow regressions / regression (push) Has been cancelled
This commit is contained in:
@@ -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"
|
||||
@@ -2,3 +2,4 @@
|
||||
/.env
|
||||
/.tools/
|
||||
/tests/.local/
|
||||
/tests/__pycache__/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);}
|
||||
@@ -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()}))
|
||||
@@ -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);}
|
||||
@@ -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.')
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
class AppException extends RuntimeException {
|
||||
public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); }
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
+37
-39
@@ -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);
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
-20
@@ -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,11 +37,7 @@ 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
|
||||
|
||||
@@ -104,18 +100,9 @@ class Journal 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
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -146,7 +134,6 @@ class Journal extends Base {
|
||||
$journal->addAttribute("end", $end);
|
||||
$journal->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $journal;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -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&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&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>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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 / 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Vendored
-4
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+12
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
|
||||
+14
-282
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;});
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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";}
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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'<script>text only</script>' 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()
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user