Below is a list of all our current active projects in Procore:
- -[procore_project_list company_id="456" show_details="true" active_only="true" sort_by="name"] - -Click on a project ID to view more details about that specific project.
-``` - -## Customizing Styles - -The plugin includes default CSS styles that you can customize to match your theme: - -1. Navigate to the plugin directory in your WordPress installation: `/wp-content/plugins/procore-integration/` -2. Edit the file at `assets/css/procore-integration.css` -3. Save your changes - -If you need to reset to the default styles, you can copy the contents from `assets/css/procore-integration-default.css` into your active CSS file. - -## Finding Your Company ID - -To locate your Procore Company ID: - -1. Log in to your Procore account -2. Look at the URL in your browser when viewing your company dashboard -3. The URL will contain a pattern like `https://app.procore.com/companies/XXXX/...` where `XXXX` is your company ID -4. Alternatively, you can use the [procore_project_list] shortcode without a company ID first, and the API error message may include information about available company IDs - -You can set a default Company ID in the plugin settings page to avoid having to specify it in every shortcode. - -## Troubleshooting - -### Common Issues - -1. **Connection Failed**: Make sure your Client ID and Client Secret are correct. Check that your Procore account has the necessary permissions. - -2. **No Data Displayed**: Ensure you're using the correct project ID and company ID in your shortcodes. Project IDs can be found in the URL when viewing a project in Procore (e.g., `https://app.procore.com/projects/123/...`). - -3. **Company ID Error**: If you're getting errors about company_id, make sure you're either specifying the correct company ID in each shortcode or have set a default company ID in the plugin settings. - -4. **Drawings or Specifications Not Showing**: Not all projects have drawings or specifications. Check that these exist in your Procore project. - -5. **API Rate Limiting**: Procore may limit the number of API requests. If you're displaying many shortcodes on a single page, consider caching the data. - -### Support - -If you encounter issues, check the following: - -1. WordPress error logs -2. Procore API documentation at [developers.procore.com](https://developers.procore.com/documentation) -3. Contact the plugin developer for support - -## Changelog - -### Version 1.0.0 -- Initial release with multi-company support -- Separated CSS into a dedicated file for easier customization -- Added modular file structure for better maintainability - -## License - -This plugin is licensed under the GPL v2 or later. - -## Credits - -Developed by [Your Name/Company] - -## Privacy - -This plugin connects to the Procore API and sends/receives data from Procore's servers. No data is shared with any third party. Please review Procore's privacy policy for information on how they handle your data. \ No newline at end of file +# Procore Connect + +[](https://github.com/ibuilder/ProcoreWP/actions/workflows/plugin-check.yml) +[](https://github.com/ibuilder/ProcoreWP/actions/workflows/phpcs.yml) +[](https://github.com/ibuilder/ProcoreWP/actions/workflows/tests.yml) +[](LICENSE) + +Publish live [Procore](https://www.procore.com/) construction project data on a WordPress site — with shortcodes, blocks, and a cached REST proxy. + +**[Documentation](https://ibuilder.github.io/ProcoreWP/)** · [Installation](https://ibuilder.github.io/ProcoreWP/installation/) · [Authentication](https://ibuilder.github.io/ProcoreWP/authentication/) · [Shortcode reference](https://ibuilder.github.io/ProcoreWP/shortcodes/) · [Upgrading from 1.x](https://ibuilder.github.io/ProcoreWP/upgrading/) + +--- + +## What it does + +Renders Procore data on the front end of a WordPress site: project directories, project detail panels, team lists, drawings, specifications, RFIs, submittals, punch lists, observations, daily logs, change orders, schedule milestones, company vendors and offices. + +Everything is **read-only**. Procore Connect never writes to Procore. + +``` +[procore_project_list company_id="4242" limit="10"] + +[procore_project id="123"] +[procore_team id="123"] +[procore_rfis id="123" status="open" limit="5"] + +[procore_data endpoint="submittals" project_id="123" columns="number,title,status,due_date"] +``` + +## Why version 2 exists + +Version 1.x could not work against the live API. It posted its token request to `api.procore.com`, but Procore serves authentication from `login.procore.com` — a different host — so no token was ever issued. It also sent the company scope as a `company_id` query parameter where Procore requires the `Procore-Company-Id` header, stored the client secret as plaintext in an autoloaded option, ran its connection test from an unverified `$_POST`, and cached nothing at all against an API with a documented ten-second spike limit. + +Version 2.0.0 is a rewrite. The full list is in [CHANGELOG.md](CHANGELOG.md). + +## Highlights + +| | | +|---|---| +| **Two auth modes** | Client Credentials via a Developer Managed Service Account, or Authorization Code on behalf of a Procore user. Chosen in the admin. | +| **Rate-limit aware** | Reads `X-Rate-Limit-*`, honours `Retry-After`, backs off exponentially with jitter, and trips a circuit breaker after repeated failures. | +| **Caches by default** | Per-endpoint lifetimes with a site-wide floor no page can undercut. Uses the transient API, so Redis and Memcached work automatically. | +| **Degrades gracefully** | A Procore outage serves the last good response instead of blanking a published page. | +| **Real pagination** | Follows Procore's `Link: rel="next"` headers, refusing any link that points off the configured API host. | +| **Encrypted at rest** | AES-256-GCM, keyed from the site's own salts. Or keep credentials out of the database entirely with `wp-config.php` constants. | +| **Private by default** | Email addresses are suppressed unless you opt in twice. Procore error messages reach administrators only. | +| **Diagnostics** | The connection test reports every stage and probes each endpoint, so you can see which Procore tool permissions you actually have. | + +## Requirements + +WordPress 6.5+ · PHP 7.4+ · A Procore account with API access + +## Quick start + +1. Install and activate the plugin. +2. **Procore → Connection** — enter your Client ID and Client Secret. +3. Click **Look up companies** and pick a default company. +4. Click **Test connection**. Each stage reports separately, and the endpoint probe shows which shortcodes your permissions support. +5. Add a shortcode or the **Procore** block to a page. + +Getting credentials is covered step by step in [the authentication guide](https://ibuilder.github.io/ProcoreWP/authentication/). + +### Keeping the secret out of the database + +```php +// wp-config.php +define( 'PROCORE_CONNECT_CLIENT_ID', 'your-client-id' ); +define( 'PROCORE_CONNECT_CLIENT_SECRET', 'your-client-secret' ); +define( 'PROCORE_CONNECT_COMPANY_ID', 4242 ); +``` + +These take precedence over the admin fields and are never written to an option. + +## Shortcodes + +| Shortcode | Shows | Procore permission | +|---|---|---| +| `[procore_project_list]` | Projects in a company | Company Admin / Project Directory | +| `[procore_project]` | One project's details | Project Admin | +| `[procore_project_data]` | One allow-listed project field | Project Admin | +| `[procore_featured_image]` | Project logo or photo | Project Admin | +| `[procore_team]` | Project directory users | Project Directory | +| `[procore_drawings]` | Drawing areas | Drawings | +| `[procore_specifications]` | Specification sections | Specifications | +| `[procore_rfis]` | RFIs | RFIs | +| `[procore_submittals]` | Submittals | Submittals | +| `[procore_punch_list]` | Punch items | Punch List | +| `[procore_observations]` | Observations | Observations | +| `[procore_daily_logs]` | Daily construction reports | Daily Log | +| `[procore_change_orders]` | Change order packages | Change Orders | +| `[procore_milestones]` | Schedule tasks | Schedule | +| `[procore_vendors]` | Company directory | Company Directory | +| `[procore_offices]` | Company offices | Company Admin | +| `[procore_project_map]` | Project locations with geo microdata | Company Admin | +| `[procore_data]` | Any published endpoint | Depends on `endpoint` | + +Shared attributes: `company_id`, `project_id`, `limit`, `page`, `orderby`, `order`, `columns`, `template`, `class`, `title`, `cache`, `empty_text`, `show_email`, `all`. + +The legacy `id` attribute from 1.x is still accepted as an alias for `project_id`, so existing pages keep working. A live, always-accurate reference is generated inside the plugin at **Procore → Shortcodes**. + +## Blocks + +A single **Procore** block registers one inserter variation per shortcode, with a settings sidebar and a live server-rendered preview. It delegates to the same renderer the shortcodes use, so markup and escaping are identical in both surfaces. + +The editor script is hand-written ES5 against the global `wp.*` runtime. There is no build step and no `node_modules` — the file that ships is the file that was authored. + +## REST proxy + +Optional, off by default. Enable it under **Procore → Tools**. + +``` +GET /wp-json/procore-connect/v1/endpoints +GET /wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25 +``` + +Read-only, served from cache, restricted to endpoints published in the registry, with access configurable between public, logged-in and editor. Credentials never reach the browser. + +## WP-CLI + +```bash +wp procore-connect test +wp procore-connect doctor +wp procore-connect projects --format=csv +wp procore-connect cache-clear --group=rfis +wp procore-connect cache-warm +wp procore-connect reset-token +``` + +## Customising output + +Copy any file from `templates/` into `yourtheme/procore-connect/` and edit it there. Overrides survive plugin updates — unlike 1.x, which told you to edit CSS inside the plugin directory and lost your work on every release. + +``` +wp-content/plugins/procore-connect/templates/collection.php + → wp-content/themes/your-theme/procore-connect/collection.php +``` + +Templates: `collection`, `record`, `field`, `image`, `map`. For styling only, use **Procore → Display → Custom CSS**. + +### Filters + +| Filter | Purpose | +|---|---| +| `procore_connect_endpoints` | Correct or add an endpoint after a Procore resource version bump | +| `procore_connect_shortcodes` | Register a shortcode, or change a column map | +| `procore_connect_allowed_project_fields` | Extend what `[procore_project_data]` may display | +| `procore_connect_api_host` / `procore_connect_login_host` | Point at a regional or federal zone | +| `procore_connect_redirect_uri` | Override the OAuth redirect URI | +| `procore_connect_request_args` | Adjust the HTTP arguments of an API request | +| `procore_connect_cache_enabled` | Disable caching programmatically | +| `procore_connect_template_candidates` | Change where templates are looked up | + +## Development + +```bash +composer install +composer run lint # PHPCS: WordPress-Extra + WordPress-Docs +composer run test # PHPUnit +composer run syntax # php -l across the tree +``` + +Tests run against JSON fixtures through an injected HTTP transport, so no Procore credentials or network access are needed. They cover token handling, the refresh mutex, 429/503 backoff, `Link` pagination, cache keys and purging, the stale fallback, encryption round-trips, endpoint allow-list refusals, and every shortcode's attribute sanitization and output escaping. + +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md). + +## Upgrading from 1.x + +Settings are imported automatically, all shortcode names are unchanged, and the legacy `id` attribute still works. + +Two things need your attention. The main plugin file was renamed from `index.php` to `procore-connect.php`, which WordPress sees as a different plugin, so **activate Procore Connect once after updating**. And because 1.x never successfully authenticated, **re-run the connection test** before assuming a blank shortcode is a bug. + +Full detail: [the upgrade guide](https://ibuilder.github.io/ProcoreWP/upgrading/). + +## Third-party service + +Procore Connect contacts Procore to retrieve the data you ask it to display: `login.procore.com` for authentication and `api.procore.com` for data, or the sandbox or regional hosts you configure. It sends your Client ID and Client Secret during authentication, plus the company and project identifiers you configure. No visitor data is sent to Procore. + +[Procore terms](https://www.procore.com/legal/termsofservice) · [Procore privacy policy](https://www.procore.com/legal/privacy) · [Procore API docs](https://developers.procore.com/documentation/introduction) + +## License + +GPL-2.0-or-later. See [LICENSE](LICENSE). + +Procore Connect is not affiliated with, endorsed by, or sponsored by Procore Technologies, Inc. "Procore" is a trademark of Procore Technologies, Inc. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..806361b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 2.0.x | ✅ | +| 1.x | ❌ — see the note below | + +Version 1.x is not supported and should not be used. It stored the Procore client secret +and access token as plaintext in an autoloaded WordPress option, ran its connection test +from an unverified `$_POST` key, and registered its settings with no sanitize callback. +It also could not authenticate against the live API. Upgrade to 2.0.0 and rotate any +credentials that were stored by 1.x, on the assumption they were exposed. + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue. + +Use GitHub's private reporting instead: +[Report a vulnerability](https://github.com/ibuilder/ProcoreWP/security/advisories/new) + +Helpful things to include: affected version, a description of the impact, reproduction +steps, and any proof-of-concept. Please do not include real Procore credentials — redact +them. + +You can expect an acknowledgement within a few days and a fix or a plan within 30 days +for confirmed issues. Credit is given in the changelog unless you prefer otherwise. + +## How credentials are handled + +- The client secret, access token and refresh token are encrypted with AES-256-GCM before + storage, keyed from the site's own WordPress salts, so a database dump without + `wp-config.php` does not disclose them. +- They are stored in a non-autoloaded option and never rendered into a form field; the + admin shows a mask. +- Sites may instead define `PROCORE_CONNECT_CLIENT_ID` and `PROCORE_CONNECT_CLIENT_SECRET` in + `wp-config.php`. These take precedence and never reach the database. This is the + recommended production setup. +- If OpenSSL is unavailable the plugin warns in the admin and recommends the constants. + +## Boundaries worth knowing about + +- Every Procore path the plugin can reach is declared in `src/Api/Endpoints.php`. The + generic `[procore_data]` shortcode and the REST proxy resolve against that registry + and cannot reach anything outside it. +- The REST proxy is off by default, is read-only, and never exposes credentials. +- `[procore_project_data]` reads from a field allow-list, not the whole payload. +- Email addresses are suppressed by default and require two independent opt-ins. +- Procore error messages are shown to users with `manage_options` only. +- `Link` headers are followed only when they point at the configured API host. + +## Scope + +In scope: this plugin's code. Out of scope: vulnerabilities in WordPress core, in +third-party plugins or themes, or in the Procore API itself — report those to their +respective maintainers. diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..77cb49e --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,77 @@ +/*! + * Procore Connect admin styles. + */ + +.procore-connect-admin .nav-tab-wrapper { + margin-bottom: 1.25rem; +} + +.procore-connect-admin code.procore-connect-copy { + cursor: pointer; + position: relative; +} + +.procore-connect-admin code.procore-connect-copy.is-copied::after { + content: attr(data-copied-label, "Copied"); + position: absolute; + inset-inline-start: 100%; + margin-inline-start: 0.5rem; + white-space: nowrap; + color: #1d7a3f; + font-weight: 600; +} + +.procore-connect-steps { + margin: 0 0 1.5rem; + padding: 0; + list-style: none; + max-width: 60rem; +} + +.procore-connect-step { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + padding: 0.625rem 0.875rem; + border-left: 4px solid #dcdcde; + background: #fff; + margin-bottom: 0.375rem; +} + +.procore-connect-step strong { + min-width: 12rem; +} + +.procore-connect-step--ok { + border-left-color: #1d7a3f; +} + +.procore-connect-step--fail { + border-left-color: #b32d2e; +} + +.procore-connect-step__message { + flex: 1 1 20rem; + color: #50575e; +} + +.procore-connect-probes { + max-width: 60rem; +} + +.procore-connect-probe--ok { + color: #1d7a3f; +} + +.procore-connect-probe--failed { + color: #b32d2e; +} + +.procore-connect-probe--skipped { + color: #7c7c7c; + font-style: italic; +} + +#procore-connect-company-list { + margin-top: 0.5rem; +} diff --git a/assets/css/procore-connect.css b/assets/css/procore-connect.css new file mode 100644 index 0000000..6f2b5df --- /dev/null +++ b/assets/css/procore-connect.css @@ -0,0 +1,229 @@ +/*! + * Procore Connect front-end styles. + * + * Do not edit this file: it is replaced on every plugin update. To restyle the + * output, either add rules under Procore -> Display -> Custom CSS, or copy a + * template from the plugin's templates/ directory into yourtheme/procore-connect/. + */ + +.procore-connect { + margin: 0 0 1.5rem; +} + +.procore-connect-title { + margin: 0 0 0.75rem; + font-size: 1.125rem; + line-height: 1.3; +} + +/* Tables --------------------------------------------------------------- */ + +.procore-connect-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.procore-connect-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; +} + +.procore-connect-table th, +.procore-connect-table td { + padding: 0.625rem 0.75rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +.procore-connect-table thead th { + font-weight: 600; + white-space: nowrap; + border-bottom-width: 2px; +} + +.procore-connect-table tbody tr:last-child td { + border-bottom: 0; +} + +/* Detail lists --------------------------------------------------------- */ + +.procore-connect-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem 1.5rem; + margin: 0; +} + +.procore-connect-detail dt { + margin: 0 0 0.125rem; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.procore-connect-detail dd { + margin: 0; +} + +/* Single fields -------------------------------------------------------- */ + +.procore-connect-field { + display: inline-flex; + gap: 0.375rem; + align-items: baseline; + margin: 0 0 0.5rem; +} + +.procore-connect-field__label { + font-weight: 600; +} + +.procore-connect-field__label::after { + content: ":"; +} + +/* Status badges -------------------------------------------------------- */ + +.procore-connect-status { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.8125rem; + line-height: 1.5; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); +} + +.procore-connect-status--active { + background: rgba(22, 128, 70, 0.12); + color: #0f5132; +} + +.procore-connect-status--inactive { + background: rgba(0, 0, 0, 0.07); + opacity: 0.8; +} + +/* Images --------------------------------------------------------------- */ + +.procore-connect-image { + margin: 0 0 1.5rem; +} + +.procore-connect-image img { + max-width: 100%; + height: auto; + display: block; +} + +/* Locations ------------------------------------------------------------ */ + +.procore-connect-map__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} + +.procore-connect-map__item { + padding: 0.875rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0.375rem; +} + +.procore-connect-map__name { + display: block; + font-weight: 600; +} + +.procore-connect-map__location { + display: block; + font-size: 0.875rem; + opacity: 0.75; +} + +.procore-connect-map__link { + display: inline-block; + margin-top: 0.375rem; + font-size: 0.875rem; +} + +/* Notices -------------------------------------------------------------- */ + +.procore-connect-notice { + padding: 0.75rem 1rem; + margin: 0 0 1.5rem; + border-left: 3px solid rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.03); + font-size: 0.9375rem; +} + +.procore-connect-notice--error { + border-left-color: #b32d2e; + background: rgba(179, 45, 46, 0.06); +} + +.procore-connect-notice--empty { + font-style: italic; + opacity: 0.85; +} + +/* Small screens -------------------------------------------------------- */ + +@media (max-width: 600px) { + .procore-connect-table, + .procore-connect-table thead, + .procore-connect-table tbody, + .procore-connect-table tr, + .procore-connect-table td { + display: block; + width: 100%; + } + + .procore-connect-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + .procore-connect-table tr { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + .procore-connect-table td { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0; + border: 0; + } + + .procore-connect-table td::before { + content: attr(data-label); + font-weight: 600; + opacity: 0.7; + } +} + +@media (prefers-color-scheme: dark) { + .procore-connect-table th, + .procore-connect-table td, + .procore-connect-map__item { + border-color: rgba(255, 255, 255, 0.15); + } + + .procore-connect-status, + .procore-connect-notice { + background: rgba(255, 255, 255, 0.07); + } +} diff --git a/assets/css/procore-integration.css b/assets/css/procore-integration.css deleted file mode 100644 index 1bec953..0000000 --- a/assets/css/procore-integration.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Procore Integration Styles */ -.procore-project { - margin-bottom: 30px; -} -.procore-project-details { - margin-top: 15px; -} -.procore-team-list, -.procore-drawings-list, -.procore-specifications-list { - list-style: none; - padding: 0; - margin: 0; -} -.procore-team-member, -.procore-drawing, -.procore-specification { - margin-bottom: 15px; - padding: 15px; - background: #f9f9f9; - border-radius: 4px; -} -.procore-member-name, -.procore-drawing-name, -.procore-spec-title { - font-weight: bold; - margin-bottom: 5px; -} -.procore-project-image img { - max-width: 100%; - height: auto; -} -.procore-project-data { - margin: 10px 0; -} -.procore-data-label { - font-weight: bold; -} -.procore-projects-table { - width: 100%; - border-collapse: collapse; - margin: 15px 0; -} -.procore-projects-table th, -.procore-projects-table td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid #ddd; -} -.procore-projects-table th { - background-color: #f2f2f2; - font-weight: bold; -} -.procore-projects-table tr:hover { - background-color: #f5f5f5; -} -.procore-project-id { - font-family: monospace; - white-space: nowrap; -} -.error { - color: #d63638; - font-weight: bold; -} \ No newline at end of file diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..8a7edaf --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,326 @@ +/** + * Procore Connect admin behaviour. + * + * Plain ES5 with no build step, so the shipped file is the source file. + * + * @package ProcoreConnect + */ + +( function () { + 'use strict'; + + var config = window.procoreConnectAdmin || {}; + var strings = config.strings || {}; + + /** + * POST to admin-ajax with the shared nonce. + * + * @param {string} action Ajax action name. + * @param {Object} data Additional payload. + * @param {Function} onDone Success callback. + * @param {Function} onFail Failure callback. + */ + function post( action, data, onDone, onFail ) { + var body = new window.FormData(); + + body.append( 'action', action ); + body.append( 'nonce', config.nonce ); + + Object.keys( data || {} ).forEach( function ( key ) { + body.append( key, data[ key ] ); + } ); + + window + .fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + } ) + .then( function ( response ) { + return response.json(); + } ) + .then( function ( payload ) { + if ( payload && payload.success ) { + onDone( payload.data || {} ); + } else { + onFail( ( payload && payload.data && payload.data.message ) || strings.failed ); + } + } ) + .catch( function () { + onFail( strings.failed ); + } ); + } + + /** + * Create an element with text content. + * + * @param {string} tag Tag name. + * @param {string} className Class attribute. + * @param {string} text Text content. + * @return {HTMLElement} The element. + */ + function el( tag, className, text ) { + var node = document.createElement( tag ); + + if ( className ) { + node.className = className; + } + + if ( undefined !== text && null !== text ) { + node.textContent = text; + } + + return node; + } + + /** + * Render the connection diagnostic report. + * + * @param {Object} report Report payload. + */ + function renderReport( report ) { + var target = document.getElementById( 'procore-connect-test-results' ); + + if ( ! target ) { + return; + } + + target.innerHTML = ''; + + var summary = el( + 'div', + 'notice notice-' + ( report.ok ? 'success' : 'warning' ) + ' procore-connect-summary' + ); + summary.appendChild( el( 'p', '', report.summary ) ); + target.appendChild( summary ); + + var list = el( 'ul', 'procore-connect-steps' ); + + ( report.steps || [] ).forEach( function ( step ) { + var item = el( 'li', 'procore-connect-step procore-connect-step--' + ( step.ok ? 'ok' : 'fail' ) ); + + item.appendChild( el( 'strong', '', step.label ) ); + item.appendChild( el( 'span', 'procore-connect-step__message', step.message ) ); + list.appendChild( item ); + } ); + + target.appendChild( list ); + + if ( ! report.probes || ! report.probes.length ) { + return; + } + + target.appendChild( el( 'h3', '', 'Endpoint permissions' ) ); + + var table = el( 'table', 'widefat striped procore-connect-probes' ); + var tbody = document.createElement( 'tbody' ); + + report.probes.forEach( function ( probe ) { + var row = document.createElement( 'tr' ); + + row.appendChild( el( 'td', '', probe.label ) ); + row.appendChild( + el( 'td', 'procore-connect-probe--' + probe.status, probe.message ) + ); + row.appendChild( el( 'td', '', probe.permission ) ); + tbody.appendChild( row ); + } ); + + table.appendChild( tbody ); + target.appendChild( table ); + } + + /** + * Wire the connection test button. + */ + function bindTest() { + var button = document.getElementById( 'procore-connect-test' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + var original = button.textContent; + + button.disabled = true; + button.textContent = strings.testing; + + post( + 'procore_connect_test_connection', + {}, + function ( report ) { + button.disabled = false; + button.textContent = original; + renderReport( report ); + }, + function ( message ) { + button.disabled = false; + button.textContent = original; + renderReport( { ok: false, summary: message, steps: [], probes: [] } ); + } + ); + } ); + } + + /** + * Wire the cache purge button. + */ + function bindCache() { + var button = document.getElementById( 'procore-connect-clear-cache' ); + var message = document.getElementById( 'procore-connect-cache-message' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + + if ( message ) { + message.textContent = strings.clearing; + } + + post( + 'procore_connect_clear_cache', + {}, + function ( data ) { + button.disabled = false; + + if ( message ) { + message.textContent = data.message || ''; + } + }, + function ( text ) { + button.disabled = false; + + if ( message ) { + message.textContent = text; + } + } + ); + } ); + } + + /** + * Wire the company lookup button. + */ + function bindCompanies() { + var button = document.getElementById( 'procore-connect-load-companies' ); + var list = document.getElementById( 'procore-connect-company-list' ); + var input = document.getElementById( 'procore-connect-company-id' ); + + if ( ! button || ! list ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + list.textContent = strings.loading; + + post( + 'procore_connect_companies', + {}, + function ( data ) { + button.disabled = false; + list.innerHTML = ''; + + var select = document.createElement( 'select' ); + + ( data.companies || [] ).forEach( function ( company ) { + var option = document.createElement( 'option' ); + + option.value = company.id; + option.textContent = company.name + ' (' + company.id + ')'; + + if ( input && String( company.id ) === String( input.value ) ) { + option.selected = true; + } + + select.appendChild( option ); + } ); + + select.addEventListener( 'change', function () { + if ( input ) { + input.value = select.value; + } + } ); + + list.appendChild( select ); + }, + function ( text ) { + button.disabled = false; + list.textContent = text; + } + ); + } ); + } + + /** + * Wire the disconnect button. + */ + function bindDisconnect() { + var button = document.getElementById( 'procore-connect-disconnect' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + if ( ! window.confirm( strings.confirmed ) ) { + return; + } + + button.disabled = true; + + post( + 'procore_connect_disconnect', + {}, + function () { + window.location.reload(); + }, + function () { + button.disabled = false; + } + ); + } ); + } + + /** + * Wire click-to-copy on code elements. + */ + function bindCopy() { + document.addEventListener( 'click', function ( event ) { + var trigger = event.target.closest( '[data-procore-connect-copy], [data-procore-connect-copy-button]' ); + + if ( ! trigger ) { + return; + } + + var source = trigger.hasAttribute( 'data-procore-connect-copy' ) + ? trigger + : trigger.parentNode.querySelector( '[data-procore-connect-copy]' ); + + if ( ! source || ! window.navigator.clipboard ) { + return; + } + + window.navigator.clipboard + .writeText( source.getAttribute( 'data-procore-connect-copy' ) ) + .then( function () { + source.classList.add( 'is-copied' ); + window.setTimeout( function () { + source.classList.remove( 'is-copied' ); + }, 1500 ); + } ); + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + bindTest(); + bindCache(); + bindCompanies(); + bindDisconnect(); + bindCopy(); + } ); +} )(); diff --git a/blocks/procore/block.json b/blocks/procore/block.json new file mode 100644 index 0000000..7b4b205 --- /dev/null +++ b/blocks/procore/block.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "procore-connect/procore", + "title": "Procore", + "category": "widgets", + "icon": "building", + "description": "Display data from Procore. Pick what to show, then set the project or company it applies to.", + "keywords": [ "procore", "construction", "project", "rfi", "submittal" ], + "textdomain": "procore-connect", + "supports": { + "html": false, + "align": [ "wide", "full" ], + "spacing": { + "margin": true, + "padding": true + }, + "color": { + "background": true, + "text": true + } + }, + "attributes": { + "shortcode": { + "type": "string", + "default": "procore_project_list" + }, + "atts": { + "type": "object", + "default": {} + } + }, + "editorScript": "file:./index.js", + "style": "procore-connect" +} diff --git a/blocks/procore/index.js b/blocks/procore/index.js new file mode 100644 index 0000000..9716885 --- /dev/null +++ b/blocks/procore/index.js @@ -0,0 +1,219 @@ +/** + * Procore Connect block editor integration. + * + * Hand-written ES5 against the global `wp.*` runtime, with no build step, so + * the file that ships is the file that was authored. + * + * @package ProcoreConnect + */ + +( function ( wp, config ) { + 'use strict'; + + if ( ! wp || ! wp.blocks || ! wp.element ) { + return; + } + + var el = wp.element.createElement; + var Fragment = wp.element.Fragment; + var __ = wp.i18n.__; + var InspectorControls = wp.blockEditor.InspectorControls; + var useBlockProps = wp.blockEditor.useBlockProps; + var PanelBody = wp.components.PanelBody; + var SelectControl = wp.components.SelectControl; + var TextControl = wp.components.TextControl; + var ToggleControl = wp.components.ToggleControl; + var Placeholder = wp.components.Placeholder; + var ServerSideRender = wp.serverSideRender; + + var variations = ( config && config.variations ) || []; + + /** + * Attributes surfaced as controls, in display order. + * + * Everything else remains reachable through the equivalent shortcode. + */ + var CONTROLS = [ + { key: 'project_id', label: __( 'Project ID', 'procore-connect' ), type: 'number' }, + { key: 'company_id', label: __( 'Company ID', 'procore-connect' ), type: 'number' }, + { key: 'endpoint', label: __( 'Endpoint', 'procore-connect' ), type: 'text' }, + { key: 'field', label: __( 'Field', 'procore-connect' ), type: 'text' }, + { key: 'label', label: __( 'Label', 'procore-connect' ), type: 'text' }, + { key: 'title', label: __( 'Heading', 'procore-connect' ), type: 'text' }, + { key: 'limit', label: __( 'Maximum rows', 'procore-connect' ), type: 'number' }, + { key: 'status', label: __( 'Filter by status', 'procore-connect' ), type: 'text' }, + { key: 'orderby', label: __( 'Sort by field', 'procore-connect' ), type: 'text' }, + { key: 'order', label: __( 'Sort direction', 'procore-connect' ), type: 'select', options: [ + { label: __( 'Ascending', 'procore-connect' ), value: 'asc' }, + { label: __( 'Descending', 'procore-connect' ), value: 'desc' }, + ] }, + { key: 'columns', label: __( 'Columns', 'procore-connect' ), type: 'text' }, + { key: 'width', label: __( 'Image width', 'procore-connect' ), type: 'number' }, + { key: 'show_email', label: __( 'Show email addresses', 'procore-connect' ), type: 'toggle' }, + { key: 'all', label: __( 'Fetch every page', 'procore-connect' ), type: 'toggle' }, + { key: 'class', label: __( 'Extra CSS classes', 'procore-connect' ), type: 'text' }, + ]; + + /** + * Find the variation definition for a shortcode tag. + * + * @param {string} shortcode Shortcode tag. + * @return {Object|null} Variation, or null. + */ + function findVariation( shortcode ) { + for ( var i = 0; i < variations.length; i++ ) { + if ( variations[ i ].shortcode === shortcode ) { + return variations[ i ]; + } + } + + return null; + } + + /** + * Build the inspector controls for the selected shortcode. + * + * @param {Object} attributes Block attributes. + * @param {Function} setAttributes Attribute setter. + * @return {Array} Control elements. + */ + function buildControls( attributes, setAttributes ) { + var variation = findVariation( attributes.shortcode ); + var supported = variation ? variation.atts : []; + var atts = attributes.atts || {}; + + /** + * Persist one attribute. + * + * @param {string} key Attribute name. + * @param {*} value New value. + */ + function update( key, value ) { + var next = Object.assign( {}, atts ); + + if ( '' === value || false === value || null === value || undefined === value ) { + delete next[ key ]; + } else { + next[ key ] = String( value ); + } + + setAttributes( { atts: next } ); + } + + return CONTROLS.filter( function ( control ) { + return -1 !== supported.indexOf( control.key ); + } ).map( function ( control ) { + var value = undefined === atts[ control.key ] ? '' : atts[ control.key ]; + + if ( 'toggle' === control.type ) { + return el( ToggleControl, { + key: control.key, + label: control.label, + checked: 'true' === value, + onChange: function ( next ) { + update( control.key, next ? 'true' : '' ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + if ( 'select' === control.type ) { + return el( SelectControl, { + key: control.key, + label: control.label, + value: value, + options: [ { label: __( 'Default', 'procore-connect' ), value: '' } ].concat( control.options ), + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + return el( TextControl, { + key: control.key, + label: control.label, + type: 'number' === control.type ? 'number' : 'text', + value: value, + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } ); + } + + wp.blocks.registerBlockType( 'procore-connect/procore', { + edit: function ( props ) { + var attributes = props.attributes; + var setAttributes = props.setAttributes; + var blockProps = useBlockProps ? useBlockProps() : {}; + var variation = findVariation( attributes.shortcode ); + + var inspector = el( + InspectorControls, + {}, + el( + PanelBody, + { title: __( 'Procore data', 'procore-connect' ), initialOpen: true }, + el( SelectControl, { + label: __( 'Show', 'procore-connect' ), + value: attributes.shortcode, + options: variations.map( function ( item ) { + return { label: item.title, value: item.shortcode }; + } ), + onChange: function ( next ) { + setAttributes( { shortcode: next } ); + }, + help: variation ? variation.description : '', + __nextHasNoMarginBottom: true, + } ) + ), + el( + PanelBody, + { title: __( 'Options', 'procore-connect' ), initialOpen: true }, + buildControls( attributes, setAttributes ) + ) + ); + + var preview = ServerSideRender + ? el( ServerSideRender, { + block: 'procore-connect/procore', + attributes: attributes, + EmptyResponsePlaceholder: function () { + return el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procore-connect' ) }, + __( 'Nothing to display yet. Check the project and company IDs in the block settings.', 'procore-connect' ) + ); + }, + } ) + : el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procore-connect' ) }, + variation ? variation.title : attributes.shortcode + ); + + return el( Fragment, {}, inspector, el( 'div', blockProps, preview ) ); + }, + + save: function () { + // Rendered server-side so cached data and escaping stay on the server. + return null; + }, + + variations: variations.map( function ( item ) { + return { + name: item.name, + title: item.title, + description: item.description, + icon: 'building', + attributes: { shortcode: item.shortcode }, + scope: [ 'inserter' ], + isActive: function ( blockAttributes ) { + return blockAttributes.shortcode === item.shortcode; + }, + }; + } ), + } ); +} )( window.wp, window.procoreConnectBlocks ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8eb3d7a --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "ibuilder/procore-connect", + "description": "Connect WordPress to the Procore construction management platform and display project data with shortcodes, blocks and a REST proxy.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "plugin", + "procore", + "construction", + "api" + ], + "homepage": "https://github.com/ibuilder/ProcoreWP", + "support": { + "issues": "https://github.com/ibuilder/ProcoreWP/issues", + "source": "https://github.com/ibuilder/ProcoreWP" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.9", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "psr-4": { + "ProcoreConnect\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ProcoreConnect\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf", + "test": "phpunit", + "syntax": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l" + } +} diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..bdb70f3 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,15 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" + +group :jekyll_plugins do + gem "jekyll-remote-theme", "~> 0.4" + gem "jekyll-seo-tag", "~> 2.8" + gem "jekyll-sitemap", "~> 1.4" +end + +# Required on Ruby 3.x, where these left the standard library. +gem "webrick", "~> 1.8" +gem "csv", "~> 3.3" +gem "base64", "~> 0.2" +gem "bigdecimal", "~> 3.1" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..7403ee7 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,38 @@ +title: Procore Connect +description: Publish live Procore construction project data on a WordPress site. +remote_theme: just-the-docs/just-the-docs + +url: https://ibuilder.github.io +baseurl: /ProcoreWP + +permalink: pretty + +plugins: + - jekyll-remote-theme + - jekyll-seo-tag + - jekyll-sitemap + +color_scheme: light +search_enabled: true +heading_anchors: true + +aux_links: + "GitHub": https://github.com/ibuilder/ProcoreWP +aux_links_new_tab: true + +nav_external_links: + - title: Procore API docs + url: https://developers.procore.com/documentation/introduction + hide_icon: false + +footer_content: >- + Procore Connect is licensed GPL-2.0-or-later. It is not affiliated with, endorsed by, or + sponsored by Procore Technologies, Inc. + +back_to_top: true +back_to_top_text: "Back to top" + +exclude: + - Gemfile + - Gemfile.lock + - vendor diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..c992bd2 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,173 @@ +--- +title: Authentication +layout: default +nav_order: 3 +--- + +# Authentication + +Procore Connect supports both of Procore's OAuth 2.0 grants. Which one you want depends on +whether the connection should belong to your organisation or to a person. + +## Which mode? + +| | Service Account (Client Credentials) | User Account (Authorization Code) | +|---|---|---| +| Acts as | A Developer Managed Service Account | A signed-in Procore user | +| Permissions | Declared in your app manifest, applied at install | Whatever that user can see | +| Survives staff changes | Yes | **No** — breaks if their access is revoked | +| Needs a redirect URI | No | Yes | +| Refresh token | None issued; a new token is simply requested | Issued, and rotated on every use | +| Best for | Almost every website | Cases needing one person's exact scope | + +**Recommendation: use the service account.** A public marketing or project site should +not depend on one employee's Procore login remaining active. + +--- + +## Service Account (Client Credentials) + +This is Procore's Developer Managed Service Account, or DMSA. The service account user is +created automatically inside the Procore company directory when a company administrator +installs your app. + +### 1. Create the app + +1. Sign in to the [Procore Developer Portal](https://developers.procore.com/). +2. Go to **My Apps** and create a new app. +3. Add an **OAuth** component and set the grant type to `client_credentials`. + +### 2. Declare permissions + +Use the Permissions Builder to declare every tool your shortcodes will read. If you skip +one, the endpoint returns 403 at runtime and the shortcode renders nothing. + +| If you use | Declare | +|---|---| +| `[procore_project_list]`, `[procore_project]` | Company Admin / Project Admin — Read Only | +| `[procore_team]` | Project Directory — Read Only | +| `[procore_vendors]` | Company Directory — Read Only | +| `[procore_offices]` | Company Admin — Read Only | +| `[procore_drawings]` | Drawings — Read Only | +| `[procore_specifications]` | Specifications — Read Only | +| `[procore_rfis]` | RFIs — Read Only | +| `[procore_submittals]` | Submittals — Read Only | +| `[procore_punch_list]` | Punch List — Read Only | +| `[procore_observations]` | Observations — Read Only | +| `[procore_daily_logs]` | Daily Log — Read Only | +| `[procore_change_orders]` | Change Orders — Read Only | +| `[procore_milestones]` | Schedule — Read Only | + +Request **read only**, and no more than you need. Procore specifically advises against +granting the service account admin access to the company Directory tool, because that +level of access permits changes across every project in the account. + +### 3. Install it + +A Procore company administrator installs the app — from the App Marketplace, or as a +custom install. During installation they choose which projects the app may access. The +service account user is provisioned automatically and the declared permissions are +applied. + +Permitted projects can be changed later under **App Management → Permissions** in +Procore. Avoid adjusting the service account's permissions by hand in the Directory tool; +that creates inconsistencies with the manifest. + +### 4. Connect WordPress + +Paste the DMSA Client ID and Client Secret into **Procore → Connection**, pick a default +company, and click **Test connection**. + +Tokens are valid for 90 minutes. No refresh token is issued for this grant — Procore Connect +simply requests a new one when the old one nears expiry, with a two-minute safety margin. + +--- + +## User Account (Authorization Code) + +### 1. Create the app + +In the Developer Portal, create an app and register the **redirect URI** exactly as shown +on **Procore → Connection** in WordPress. There is a copy button next to it. It looks +like: + +``` +https://example.com/wp-admin/admin-post.php?action=procore_connect_oauth_callback +``` + +It must match character for character, including the scheme and any `www`. + +### 2. Connect + +1. Set the authentication mode to **User Account** and save. +2. Enter the Client ID and Client Secret and save. +3. Click **Connect to Procore**, authorise the app, and you will be returned to WordPress. + +### How the round trip is protected + +Procore Connect generates a single-use `state` value, stores it in a short-lived transient, and +refuses any callback whose `state` does not match. The outbound leg additionally requires +a WordPress nonce and the `manage_options` capability. This is what stops someone +grafting their own Procore account onto your site by feeding you a crafted callback URL. + +### Refresh tokens rotate + +Procore invalidates a refresh token the moment it is exchanged and issues a new one. +Two concurrent requests both refreshing would leave one holding a dead token and lock the +site out of the API entirely. + +Procore Connect guards every refresh with a lock. A request that loses the race waits briefly +and re-reads the token the winner stored, rather than refreshing again. If a refresh token +is ever rejected outright, the connection is cleared and the admin is told to reconnect — +there is no recovery from a rotated-away refresh token. + +--- + +## Where credentials are stored + +| | | +|---|---| +| At rest | AES-256-GCM, keyed from the site's WordPress salts | +| Option | Non-autoloaded, so it is not read on every page request | +| In the admin | Masked — the real secret is never rendered into a form field | +| Best practice | `PROCORE_CONNECT_CLIENT_SECRET` in `wp-config.php`, never in the database | + +A blank Client Secret field on save means "leave the stored value alone". To remove a +stored secret, tick **Delete the stored secret**. + +If OpenSSL is unavailable, Procore Connect says so in an admin notice and falls back to +unencrypted storage — use the `wp-config.php` constants on such a host. + +--- + +## Finding your company ID + +Click **Look up companies** on the Connection screen and pick from the list. Failing that, +it is in your Procore URL: + +``` +https://app.procore.com/companies/4242/... + ^^^^ +``` + +Project IDs appear the same way: `https://app.procore.com/projects/123/...` + +--- + +## Verifying it works + +**Procore → Connection → Test connection** runs through: + +1. **Configuration** — credentials present, encryption available, which hosts are in use +2. **Access token** — obtained, via which grant, and how long it remains valid +3. **Authenticated account** — who Procore thinks you are +4. **Company access** — which companies the credentials can reach +5. **Default company** — whether projects are readable, and how many +6. **Rate limit** — remaining headroom and when the window resets + +Then it probes every registered endpoint and reports each as readable, failed or skipped, +alongside the Procore permission that endpoint needs. + +That probe table is the fastest way to diagnose an empty shortcode: a valid token whose +service account lacks read permission on one specific tool is by far the most common +real-world failure. diff --git a/docs/blocks.md b/docs/blocks.md new file mode 100644 index 0000000..a1a926d --- /dev/null +++ b/docs/blocks.md @@ -0,0 +1,67 @@ +--- +title: Blocks +layout: default +nav_order: 5 +--- + +# Blocks + +Procore Connect registers a single **Procore** block with one inserter variation per shortcode. +Search the inserter for "Procore" and you will see entries for RFIs, submittals, the +project list and the rest. + +Blocks are on by default and can be turned off under **Procore → Tools**. Shortcodes work +either way, including inside the Shortcode block. + +## Using it + +1. Insert a **Procore: …** block, or insert **Procore** and pick what to show from the + sidebar. +2. Set the project or company ID in **Options**. +3. The preview renders live, using the same code the front end uses. + +The sidebar only offers the attributes that apply to the selected data type — an image +block shows a width control, a list block shows sorting and limits. Anything not surfaced +in the UI is still reachable through the equivalent shortcode. + +| Control | Applies to | +|---|---| +| Show | All — which data to render | +| Project ID / Company ID | All | +| Endpoint | The generic reader | +| Field / Label | Single field | +| Heading | Lists and records | +| Maximum rows | Lists | +| Filter by status | Project tools | +| Sort by field / direction | Lists | +| Columns | Lists | +| Image width | Featured image | +| Show email addresses | Team | +| Fetch every page | Lists | +| Extra CSS classes | All | + +Alignment, spacing and colour come from WordPress's own block supports. + +## How it renders + +The block is server-rendered: `save()` returns `null`, and the front end calls the same +shortcode handler. That means markup, escaping, caching and error handling are identical +between a block and a shortcode — there is no second rendering path to keep in sync. + +It also means credentials and cached payloads never reach the editor as block content; +the editor requests a rendered preview from the server like any other dynamic block. + +## No build step + +The editor script is hand-written ES5 against the global `wp.*` runtime. There is no +`node_modules`, no bundler and no compiled output. + +This is deliberate. Shipping a minified bundle without its source is a wordpress.org +review flag, it makes the plugin unauditable, and it puts Node between a contributor and +a one-line fix. The file in `blocks/procore/index.js` is the file that runs. + +## Turning blocks off + +**Procore → Tools → Register Procore Connect blocks.** Existing block content stops rendering +while this is off; converting those blocks to their shortcode equivalents first avoids +surprises. diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..4cec413 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,139 @@ +--- +title: Caching & rate limits +layout: default +nav_order: 6 +--- + +# Caching and rate limits + +## Why this matters + +Procore enforces two rate limits at once: an hourly window and a ten-second spike window. +The API returns whichever you are closer to breaching, along with three headers on every +response: + +| Header | Meaning | +|---|---| +| `X-Rate-Limit-Limit` | Requests allowed in the reported window | +| `X-Rate-Limit-Remaining` | Requests left in it | +| `X-Rate-Limit-Reset` | Unix timestamp when it resets | + +A page with six uncached Procore Connect shortcodes makes six or more API calls **per visitor**. +On any site with real traffic that exhausts the spike limit within minutes and the page +starts erroring for everyone. + +ProcoreWP 1.x had no caching at all — its own README acknowledged the problem and left it +to the reader. Version 2 caches by default. + +## How caching works + +Every response lands in two places: + +- a **fresh** entry with the endpoint's configured lifetime, which satisfies normal reads +- a **stale** copy with a much longer lifetime, used only when Procore is unreachable + +Storage goes through the WordPress transient API, so a site running Redis or Memcached +gets that behaviour automatically with no configuration. + +Cache keys include the endpoint, the request arguments, the company, the project **and the +environment** — so switching between production and a sandbox never serves data from the +other. + +### Default lifetimes + +| Data | Default | +|---|---| +| RFIs, submittals, punch list, observations | 10 minutes | +| Projects, project detail | 15 minutes | +| Team, vendors, daily logs, change orders, schedule | 30 minutes | +| Drawings, specifications, offices | 6 hours | +| Companies | 1 hour | + +Override per shortcode with `cache="900"`, or globally via the +[`procore_connect_endpoints`](../shortcodes/#the-generic-reader) filter. + +### The floor + +**Procore → Cache → Minimum cache lifetime** (default 300 seconds) is a hard floor. A +shortcode may request a *longer* lifetime but never a shorter one. + +This is deliberate: without it, one page author writing `cache="1"` could take the whole +site over the rate limit, and the person who has to fix it is not the person who wrote the +shortcode. + +## When Procore is unavailable + +Two mechanisms keep a published page from breaking. + +**Stale fallback.** If a request fails and a stale copy exists, that copy is served and +the failure is logged. A Procore outage degrades the freshness of a project page rather +than blanking it. + +**Circuit breaker.** After five consecutive failures, requests pause for five minutes and +cached data is served exclusively. This stops a site hammering an API that is already +struggling, and stops every page view paying a timeout. The state is shown on +**Procore → Status** and in an admin notice, and it clears itself. + +## Retries and backoff + +| Response | Behaviour | +|---|---| +| `429 Too Many Requests` | Wait for `Retry-After`, or `X-Rate-Limit-Reset`, then retry | +| `503 Service Unavailable` | Wait for `Retry-After`, then retry | +| `401 Unauthorized` | Discard the token and retry once with a fresh one | +| Transport failure | Exponential backoff with jitter | +| Other `4xx` | Fail immediately — retrying will not help | + +At most three attempts per request, and never longer than a ten-second pause. + +## Pagination + +Procore accepts `page` and `per_page` (keep it at or below 2000) and returns `Total`, +`Per-Page` and `Link` headers. + +With `all="true"`, Procore Connect walks the `Link: rel="next"` chain rather than incrementing +page numbers — following the links avoids the off-by-one at the end of the result set, +because the `next` link simply disappears on the final page. + +Two safeguards: a hard ceiling of 20 pages, so a misconfigured shortcode cannot pull tens +of thousands of records into memory (a warning is logged when it truncates); and any +`Link` pointing at a host other than the configured API host is refused outright. + +## Managing the cache + +**Admin** — **Procore → Cache** shows the entry count and offers a purge button. + +**WP-CLI** + +```bash +wp procore-connect cache-clear +wp procore-connect cache-clear --group=rfis +wp procore-connect cache-warm +``` + +**Cron** — an hourly job refreshes the project list for the default company, so the first +visitor after a cache expiry does not pay for the API call. Scheduled on activation, +removed on deactivation. + +**Programmatically** + +```php +// Disable caching entirely — not advisable on a public site. +add_filter( 'procore_connect_cache_enabled', '__return_false' ); + +// Purge one endpoint group after your own sync. +ProcoreConnect\Api\Cache::flush( 'projects' ); + +// React to a purge. +add_action( 'procore_connect_cache_flushed', function ( string $group, int $removed ): void { + error_log( "Procore Connect purged {$removed} entries from {$group}" ); +}, 10, 2 ); +``` + +## Monitoring + +**Procore → Status** reports remaining rate-limit headroom, circuit breaker state, token +expiry, cache statistics and whether a persistent object cache is in use. + +Turn on **Procore → Tools → Record API diagnostics** to keep the last 50 events. Client +secrets, tokens and authorization codes are redacted before anything is written. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a3bc448 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ +--- +title: Home +layout: default +nav_order: 1 +--- + +# Procore Connect + +Publish live [Procore](https://www.procore.com/) construction project data on a WordPress +site — with shortcodes, blocks, and a cached REST proxy. + +Everything Procore Connect does is **read-only**. It never writes to Procore. + +``` +[procore_project_list company_id="4242" limit="10"] +[procore_project id="123"] +[procore_rfis id="123" status="open" limit="5"] +``` + +--- + +## Start here + +| | | +|---|---| +| [Installation](installation/) | Get the plugin running | +| [Authentication](authentication/) | Create a Procore app and connect | +| [Shortcodes](shortcodes/) | Full reference for all eighteen | +| [Blocks](blocks/) | Using Procore Connect in the block editor | +| [Caching & rate limits](caching/) | How Procore Connect stays inside Procore's quotas | +| [Templating & styling](templating/) | Change the markup and the CSS | +| [REST API](rest-api/) | The optional read-only proxy | +| [WP-CLI](wp-cli/) | Command line operations | +| [Troubleshooting](troubleshooting/) | When a shortcode shows nothing | +| [Upgrading from 1.x](upgrading/) | What changed and what you must do | + +--- + +## What it can display + +**Projects and company** — project directories, project detail panels, single project +fields, project images, team members, company vendors, company offices, and project +locations with geo microdata. + +**Documents** — drawing areas and specification sections. + +**Project tools** — RFIs, submittals, punch lists, observations, daily logs, change +orders and schedule milestones. + +**Anything else** — the generic `[procore_data]` shortcode renders any endpoint published +in the plugin's [endpoint registry](shortcodes/#the-generic-reader). + +--- + +## Why version 2 is a rewrite + +Version 1.x could not work against the live Procore API. It sent its token request to +`api.procore.com`, but Procore serves authentication from `login.procore.com` — a +different host — so no access token was ever issued, on any install, regardless of +configuration. + +Underneath that, it sent the company scope as a query parameter where Procore requires a +request header, stored the client secret as plaintext in an option loaded on every page +view, ran its connection test from an unverified `POST`, and cached nothing at all +against an API with a documented ten-second spike limit. + +Version 2.0.0 fixes all of that and adds two-mode authentication, caching, rate-limit +handling, encrypted credential storage, eleven new shortcodes, blocks, a REST proxy and +WP-CLI. The complete list is in the +[changelog](https://github.com/ibuilder/ProcoreWP/blob/main/CHANGELOG.md). + +--- + +## Requirements + +- WordPress 6.5 or later +- PHP 7.4 or later +- A Procore account with API access + +--- + +## Third-party service + +Procore Connect contacts Procore to retrieve the data you ask it to display: `login.procore.com` +for authentication and `api.procore.com` for data, or the sandbox or regional hosts you +configure. + +It sends your Client ID and Client Secret during authentication, plus the company and +project identifiers you configure. **No visitor data is sent to Procore.** + +[Procore terms of service](https://www.procore.com/legal/termsofservice) · +[Procore privacy policy](https://www.procore.com/legal/privacy) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c89657e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,104 @@ +--- +title: Installation +layout: default +nav_order: 2 +--- + +# Installation + +## Requirements + +- WordPress 6.5 or later +- PHP 7.4 or later +- A Procore account with API access +- OpenSSL (present on essentially every host; without it, credentials cannot be encrypted + at rest and you should use the `wp-config.php` constants below instead) + +## Install the plugin + +### From a release ZIP + +1. Download the latest ZIP from + [Releases](https://github.com/ibuilder/ProcoreWP/releases). +2. In WordPress, go to **Plugins → Add New → Upload Plugin**. +3. Choose the ZIP, click **Install Now**, then **Activate**. + +### With Composer + +```bash +composer require ibuilder/procore-connect +``` + +### From source + +```bash +cd wp-content/plugins +git clone https://github.com/ibuilder/ProcoreWP.git procore-connect +``` + +No build step is required. The plugin ships ready to run — the block editor script is +hand-written ES5, not a compiled bundle. + +## First run + +1. Go to **Procore → Connection**. +2. Choose an authentication mode. Use **Service Account (Client Credentials)** unless you + have a specific reason not to — see [Authentication](../authentication/). +3. Enter your Client ID and Client Secret. +4. Click **Look up companies** and pick a default company. +5. Click **Test connection**. + +The connection test reports each stage separately and then probes every endpoint, so you +can see exactly which Procore tool permissions your credentials hold. That table is +usually the fastest answer to "why is my shortcode empty". + +## Keeping credentials out of the database + +By default the client secret is encrypted with AES-256-GCM before it is stored. For the +strongest protection, define it in `wp-config.php` instead — these constants take +precedence over the admin fields and are never written to an option: + +```php +define( 'PROCORE_CONNECT_CLIENT_ID', 'your-client-id' ); +define( 'PROCORE_CONNECT_CLIENT_SECRET', 'your-client-secret' ); +define( 'PROCORE_CONNECT_COMPANY_ID', 4242 ); +``` + +When these are set, the Connection screen shows a note in place of the credential fields. + +## Environments + +**Procore → Connection → Environment** selects which Procore hosts are used: + +| Environment | Login host | API host | +|---|---|---| +| Production | `login.procore.com` | `api.procore.com` | +| Developer Sandbox | `login-sandbox.procore.com` | `sandbox.procore.com` | +| Monthly Sandbox | `login-sandbox-monthly.procore.com` | `api-monthly.procore.com` | +| Custom | your own HTTPS host | your own HTTPS host | + +Each environment needs its own credentials — tokens are not shared between them. Choose +**Custom** for regional or federal zones and enter both hosts; only HTTPS is accepted. + +When a non-production environment is selected, an admin notice makes that obvious, so +nobody mistakes sandbox data for live data on a published page. + +## Adding data to a page + +Add a shortcode: + +``` +[procore_project_list limit="10"] +``` + +Or insert the **Procore** block and pick what to show from the sidebar. Both render +through the same code, so they produce identical markup. + +## Uninstalling + +Deleting the plugin removes its settings, credentials, tokens, cached responses and +diagnostic log. To keep them — for example when moving hosts — tick **Keep settings and +cached data when the plugin is deleted** under **Procore → Tools** before deleting. + +Deactivating (rather than deleting) clears the cache and the cron job but leaves settings +in place. diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 0000000..a4486ad --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,115 @@ +--- +title: REST API +layout: default +nav_order: 8 +--- + +# REST proxy + +An optional read-only proxy that lets your theme or front-end JavaScript read Procore +data without the browser ever seeing a Procore credential. + +**Off by default.** Enable it under **Procore → Tools → Expose cached Procore data**. + +## Access levels + +| Level | Who can read | +|---|---| +| Anyone | Unauthenticated visitors | +| Logged-in users | Any authenticated user *(default)* | +| Users who can edit posts | Anyone with `edit_posts` | + +Think about this before choosing **Anyone**. Even though only allow-listed endpoints are +reachable, a public route makes your project data machine-readable to anyone who finds +the URL. Whether that is a problem depends entirely on what is in your Procore account. + +## Endpoints + +### List what is available + +``` +GET /wp-json/procore-connect/v1/endpoints +``` + +```json +{ + "endpoints": [ + { "slug": "projects", "label": "Projects", "scope": "company", + "fields": [ "id", "name", "project_number", "city", "state_code", "active" ] }, + { "slug": "rfis", "label": "RFIs", "scope": "project", + "fields": [ "id", "number", "subject", "status", "due_date" ] } + ] +} +``` + +### Read data + +``` +GET /wp-json/procore-connect/v1/data/{endpoint} +``` + +| Parameter | Default | Notes | +|---|---|---| +| `company_id` | Site default | | +| `project_id` | — | Required for project-scoped endpoints | +| `per_page` | `100` | 1–2000 | +| `page` | `1` | | + +``` +GET /wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25 +``` + +```json +{ + "endpoint": "rfis", + "cached": true, + "stale": false, + "total": 42, + "data": [ { "id": 1, "number": "RFI-001", "subject": "Slab depth", "status": "open" } ] +} +``` + +`cached` tells you the response came from the plugin's cache rather than a live API call; +`stale` tells you Procore was unreachable and this is the last good copy. Responses also +carry an `X-Procore Connect-Cached` header. + +## Example + +```js +const response = await fetch( + '/wp-json/procore-connect/v1/data/rfis?project_id=123&per_page=25', + { credentials: 'same-origin', headers: { 'X-WP-Nonce': wpApiSettings.nonce } } +); + +const { data, cached } = await response.json(); +``` + +Omit the nonce only if the access level is set to **Anyone**. + +## What it will not do + +- **Write.** Every route is `GET`. There is no write path in the plugin at all. +- **Reach arbitrary endpoints.** Only slugs marked public in the endpoint registry + resolve. Identity endpoints such as `me` and `companies` are not published and return + 404. +- **Leak credentials.** The Client ID, Client Secret and tokens stay server-side. +- **Bypass the cache.** Requests are served from the same cache the shortcodes use, so + exposing the proxy cannot blow your Procore rate limit. +- **Leak error detail.** Procore's own error messages — which name accounts and + permissions — reach administrators only. Everyone else gets a neutral message. + +## Errors + +| Status | Meaning | +|---|---| +| `404` | The proxy is disabled, or the endpoint is not published | +| `401` / `403` | The access level excludes this request | +| `400` | Missing required context, e.g. no `project_id` on a project endpoint | +| `502` | Procore was unreachable and no cached copy existed | + +## Publishing your own endpoint + +Add it to the registry with `'public' => true` — see +[the shortcode reference](../shortcodes/#the-generic-reader). It becomes reachable from +both `[procore_data]` and this proxy, so only publish endpoints whose payload is safe to +expose at the access level you have chosen. diff --git a/docs/shortcodes.md b/docs/shortcodes.md new file mode 100644 index 0000000..c451715 --- /dev/null +++ b/docs/shortcodes.md @@ -0,0 +1,271 @@ +--- +title: Shortcodes +layout: default +nav_order: 4 +--- + +# Shortcode reference + +Every 1.x shortcode name is preserved, and the legacy `id` attribute still works as an +alias for `project_id`, so pages written for the old plugin keep rendering. + +A live version of this reference — generated from the same registry the code uses, so it +can never drift — is inside the plugin at **Procore → Shortcodes**. + +--- + +## Shared attributes + +Accepted by every shortcode. + +| Attribute | Default | Notes | +|---|---|---| +| `company_id` | Site default | Falls back to **Procore → Connection → Default company** | +| `project_id` | Site default | `id` is accepted as an alias | +| `limit` | `0` (all) | Caps the rendered rows | +| `page` | `1` | Which page of results to request | +| `orderby` | — | Any field path, e.g. `name` or `vendor.name` | +| `order` | `asc` | `asc` or `desc` | +| `columns` | Per shortcode | Comma-separated field paths | +| `template` | Per shortcode | Template name, overridable in your theme | +| `class` | — | Extra CSS classes, sanitized | +| `title` | Per shortcode | Heading text; pass `-` to omit the heading | +| `cache` | Endpoint default | Lifetime in seconds; cannot go below the site floor | +| `empty_text` | "No records were found." | Shown when nothing comes back | +| `show_email` | `false` | See [Privacy](#privacy) | +| `all` | `false` | Follow pagination and collect every page | + +Records missing the `orderby` field always sort last, in both directions. + +--- + +## Projects + +### `[procore_project_list]` + +A table of projects in a company. + +``` +[procore_project_list company_id="4242" limit="10" active_only="true" orderby="name"] +``` + +| Attribute | Default | Notes | +|---|---|---| +| `active_only` | `false` | Hide inactive projects | + +Default columns: ID, project name, project number, location, status. + +### `[procore_project]` + +One project as a labelled detail panel. Empty fields are omitted rather than rendered +blank. + +``` +[procore_project id="123"] +``` + +### `[procore_project_data]` + +A single field from a project record. + +``` +[procore_project_data id="123" field="start_date" label="Ground broken"] +[procore_project_data id="123" field="total_value" format="currency"] +``` + +| Attribute | Notes | +|---|---| +| `field` | **Required.** Must be allow-listed | +| `label` | Defaults to a title-cased version of the field name | +| `format` | `date`, `currency`, `number`, or omit to infer from the field name | + +Fields are allow-listed rather than arbitrary, because a Procore project payload contains +internal identifiers and personal data that should not be publishable by typing a field +name into a page. Fields ending `_date` or `_at` format as dates automatically; fields +ending `value`, `budget`, `total` or `amount` format as currency. + +To publish a field that is not on the list: + +```php +add_filter( 'procore_connect_allowed_project_fields', function ( array $fields ): array { + $fields[] = 'custom_field_name'; + return $fields; +} ); +``` + +### `[procore_featured_image]` + +The project logo or photo. + +``` +[procore_featured_image id="123" width="600"] +``` + +| Attribute | Default | +|---|---| +| `width` | `300` | +| `height` | auto | +| `alt` | The project name | +| `lazy` | `true` | + +### `[procore_project_map]` + +Projects that have coordinates, as an accessible list carrying geo microdata. + +``` +[procore_project_map limit="20"] +``` + +No mapping library is loaded. Bundling one would send visitor IP addresses to a +third party without consent, and remotely hosted assets are not permitted on +wordpress.org. Instead each item carries `data-latitude` and `data-longitude`, and the +container carries the full point set as JSON in `data-procore-connect-points` — so a theme can +attach whichever mapping provider the site already licenses. Each entry also links to +OpenStreetMap. + +--- + +## People and companies + +### `[procore_team]` + +``` +[procore_team id="123"] +[procore_team id="123" show_email="true"] +``` + +Columns: name, role, company, email. + +Email output requires **two** opt-ins — see [Privacy](#privacy). + +### `[procore_vendors]` + +Companies in the Procore company directory. + +``` +[procore_vendors orderby="name" limit="50"] +``` + +### `[procore_offices]` + +``` +[procore_offices] +``` + +--- + +## Documents + +### `[procore_drawings]` + +``` +[procore_drawings id="123" limit="5"] +``` + +### `[procore_specifications]` + +``` +[procore_specifications id="123" limit="10"] +``` + +--- + +## Project tools + +All accept `status` to filter, plus the shared attributes. + +``` +[procore_rfis id="123" status="open" limit="5"] +[procore_submittals id="123" orderby="due_date"] +[procore_punch_list id="123" status="open"] +[procore_observations id="123"] +[procore_daily_logs id="123" limit="7"] +[procore_change_orders id="123"] +[procore_milestones id="123"] +``` + +| Shortcode | Default columns | +|---|---| +| `procore_rfis` | Number, subject, status, due date | +| `procore_submittals` | Number, title, status, due date | +| `procore_punch_list` | Item, description, status, due date | +| `procore_observations` | Number, observation, status, due date | +| `procore_daily_logs` | Date, notes, status | +| `procore_change_orders` | Number, title, status, value | +| `procore_milestones` | Task, start, finish, percent complete | + +--- + +## The generic reader + +### `[procore_data]` + +Renders any endpoint published in the plugin's registry, without needing a bespoke +shortcode. + +``` +[procore_data endpoint="rfis" project_id="123" columns="number,subject,status,due_date"] +``` + +| Attribute | Notes | +|---|---| +| `endpoint` | **Required.** Must be a published registry slug | + +Published endpoints: `projects`, `project`, `project_users`, `project_vendors`, +`company_vendors`, `offices`, `drawing_areas`, `drawing_revisions`, +`specification_sections`, `rfis`, `submittals`, `punch_items`, `observations`, +`daily_logs`, `change_orders`, `milestones`. + +Identity endpoints such as `me` and `companies` are deliberately **not** published and +cannot be reached from a page. + +To register your own — useful when Procore bumps a resource version and a path changes: + +```php +add_filter( 'procore_connect_endpoints', function ( array $endpoints ): array { + $endpoints['meetings'] = array( + 'label' => 'Meetings', + 'path' => '/rest/v1.0/projects/{project_id}/meetings', + 'scope' => 'project', + 'paginated' => true, + 'ttl' => 900, + 'permission' => 'Meetings: Read Only', + 'fields' => array( 'id', 'title', 'date' ), + 'public' => true, + ); + return $endpoints; +} ); +``` + +Nothing outside this registry is callable. That boundary is what makes both +`[procore_data]` and the public REST proxy safe to expose. + +--- + +## Privacy + +Procore project directories contain personal data. Publishing them exposes staff and +subcontractors to address harvesting, so Procore Connect will not output an email address +unless **both** of the following are true: + +1. **Procore → Display → Never output email addresses** is unticked, and +2. the individual shortcode carries `show_email="true"`. + +Even then, addresses pass through `antispambot()`. + +Procore's own error messages routinely name accounts, projects and permissions. Those are +shown to users with `manage_options` only; everyone else sees a neutral notice. + +--- + +## Caching per shortcode + +`cache` sets a lifetime in seconds for that shortcode's data: + +``` +[procore_rfis id="123" cache="300"] +``` + +It can only lengthen the lifetime, never shorten it below the site floor set in +**Procore → Cache**. A page author cannot configure a page into a rate-limit breach. See +[Caching & rate limits](../caching/). diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..83ebd6e --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,175 @@ +--- +title: Templating & styling +layout: default +nav_order: 7 +--- + +# Templating and styling + +## Do not edit the plugin + +ProcoreWP 1.x told you to edit `assets/css/procore-integration.css` inside the plugin +folder. Every plugin update overwrote it and your work was gone. + +There are two supported ways to change the output, and both survive updates. + +## Custom CSS + +**Procore → Display → Custom CSS.** Added inline after the plugin stylesheet, so your +rules win without needing `!important`. Markup is stripped on save. + +```css +.procore-connect-table thead th { + background: #0d3b66; + color: #fff; +} + +.procore-connect-status--active { + background: #d1f4dd; +} +``` + +To drop the plugin stylesheet entirely and style everything from your theme, untick +**Load the Procore Connect stylesheet**. + +## Template overrides + +Copy a template out of the plugin and into your theme: + +``` +wp-content/plugins/procore-connect/templates/collection.php + → wp-content/themes/your-theme/procore-connect/collection.php +``` + +The lookup order is child theme, then parent theme, then the plugin's own default. + +| Template | Used by | +|---|---| +| `collection.php` | Every list shortcode | +| `record.php` | `[procore_project]` | +| `field.php` | `[procore_project_data]` | +| `image.php` | `[procore_featured_image]` | +| `map.php` | `[procore_project_map]` | + +### Per-shortcode templates + +Add a file and point a shortcode at it: + +``` +[procore_rfis id="123" template="rfi-cards"] +``` + +Resolves to `yourtheme/procore-connect/rfi-cards.php`. + +### What a template receives + +Everything arrives in `$data`. + +```php +$data['rows'] // array of records (collection templates) +$data['record'] // single record (record template) +$data['columns'] // column definitions: key, label, format +$data['title'] // heading text +$data['class'] // sanitized class attribute +$data['show_email'] // whether the shortcode opted in to email output +$data['atts'] // the sanitized shortcode attributes +$data['tag'] // the shortcode tag +``` + +### Escaping + +Get cell content from `Format::cell()`. It escapes everything it returns, and the only +markup it emits is a status badge or a link. + +```php + +Enter your Procore API credentials below. Learn more about Procore API authentication.
'; - } - - /** - * Render client ID field - */ - public function render_client_id_field() { - $value = isset($this->settings['client_id']) ? $this->settings['client_id'] : ''; - echo ''; - } - - /** - * Render client secret field - */ - public function render_client_secret_field() { - $value = isset($this->settings['client_secret']) ? $this->settings['client_secret'] : ''; - echo ''; - } - - /** - * Render API URL field - */ - public function render_api_url_field() { - $value = isset($this->settings['api_url']) ? $this->settings['api_url'] : 'https://api.procore.com'; - echo ''; - } - - /** - * Render default company ID field - */ - public function render_default_company_id_field() { - $value = isset($this->settings['default_company_id']) ? $this->settings['default_company_id'] : ''; - echo ''; - echo 'Enter your default Procore Company ID. This will be used if not specified in shortcodes.
'; - } - - /** - * Render settings page - */ - public function render_settings_page() { - if (!current_user_can('manage_options')) { - return; - } - - // Check if we need to test the connection - if (isset($_POST['test_connection'])) { - $this->test_connection(); - } - - // Display settings form - ?> -Click the button below to test your Procore API connection.
- - -Use these shortcodes to display Procore information on your WordPress site:
-[procore_project_list company_id="123"] - Display a list of all project IDs and names[procore_project id="123" company_id="123"] - Display project information[procore_team id="123" company_id="123"] - Display project team members[procore_featured_image id="123" company_id="123"] - Display project featured image[procore_drawings id="123" company_id="123"] - Display project drawings[procore_specifications id="123" company_id="123"] - Display project specifications[procore_project_data id="123" company_id="123" field="field_name"] - Display specific project dataError: Project ID is required
'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return 'Error: ' . esc_html($project->get_error_message()) . '
'; - } - - ob_start(); - ?> -Address:
-City:
-State:
-Zip:
-Start Date:
-Completion Date:
-Status:
-Error: Project ID is required
'; - } - - $team = $this->get_project_team($atts['id'], $atts['company_id']); - if (is_wp_error($team)) { - return 'Error: ' . esc_html($team->get_error_message()) . '
'; - } - - if (empty($team)) { - return 'No team members found for this project.
'; - } - - ob_start(); - ?> -Error: Project ID is required
'; - } - - $image_url = $this->get_project_image($atts['id'], $atts['company_id']); - if (is_wp_error($image_url)) { - return 'Error: ' . esc_html($image_url->get_error_message()) . '
'; - } - - if (empty($image_url)) { - return 'No featured image available for this project.
'; - } - - ob_start(); - ?> -Error: Project ID is required
'; - } - - $drawings = $this->get_project_drawings($atts['id'], $atts['company_id']); - if (is_wp_error($drawings)) { - return 'Error: ' . esc_html($drawings->get_error_message()) . '
'; - } - - if (empty($drawings)) { - return 'No drawings found for this project.
'; - } - - // Limit the number of drawings - $limit = intval($atts['limit']); - if ($limit > 0 && count($drawings) > $limit) { - $drawings = array_slice($drawings, 0, $limit); - } - - ob_start(); - ?> -Error: Project ID is required
'; - } - - $specs = $this->get_project_specifications($atts['id'], $atts['company_id']); - if (is_wp_error($specs)) { - return 'Error: ' . esc_html($specs->get_error_message()) . '
'; - } - - if (empty($specs)) { - return 'No specifications found for this project.
'; - } - - // Limit the number of specifications - $limit = intval($atts['limit']); - if ($limit > 0 && count($specs) > $limit) { - $specs = array_slice($specs, 0, $limit); - } - - ob_start(); - ?> -Error: Project ID is required
'; - } - - if (empty($atts['field'])) { - return 'Error: Field name is required
'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return 'Error: ' . esc_html($project->get_error_message()) . '
'; - } - - $field = $atts['field']; - $value = isset($project[$field]) ? $project[$field] : ''; - - if (empty($value)) { - return 'No data available for field "' . esc_html($field) . '"
'; - } - - // Format value based on type - if (is_array($value)) { - $value = implode(', ', $value); - } elseif (is_bool($value)) { - $value = $value ? 'Yes' : 'No'; - } - - $label = !empty($atts['label']) ? $atts['label'] : ucwords(str_replace('_', ' ', $field)); - - ob_start(); - ?> -Error: ' . esc_html($projects->get_error_message()) . '
'; - } - - if (empty($projects)) { - return 'No projects found.
'; - } - - // Filter projects by active status if needed - if ($atts['active_only'] === 'true') { - $projects = array_filter($projects, function($project) { - return isset($project['active']) && $project['active'] === true; - }); - } - - // Sort projects - $sort_by = $atts['sort_by']; - $sort_order = strtolower($atts['sort_order']) === 'desc' ? SORT_DESC : SORT_ASC; - - if (in_array($sort_by, ['name', 'id', 'created_at'])) { - $sort_column = array_column($projects, $sort_by); - array_multisort($sort_column, $sort_order, $projects); - } - - // Limit the number of projects if needed - $limit = intval($atts['limit']); - if ($limit > 0 && count($projects) > $limit) { - $projects = array_slice($projects, 0, $limit); - } - - $show_details = $atts['show_details'] === 'true'; - - ob_start(); - ?> -| ID | -Project Name | - -Location | -Status | - -
|---|---|---|---|
| - | - - | - - | -- - | - -
%2$s
' . esc_html__( 'Register this exact redirect URI in the Procore Developer Portal before connecting:', 'procore-connect' ) . '
'; + + printf( + '%1$s
'; + + if ( '' !== TokenStore::refresh_token() ) { + printf( + '', + esc_html__( 'Disconnect from Procore', 'procore-connect' ) + ); + } else { + printf( + '%s', + esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=procore_connect_oauth_connect' ), OAuthController::NONCE ) ), + esc_html__( 'Connect to Procore', 'procore-connect' ) + ); + } + + echo '
'; + }//end if + + echo '' . esc_html__( 'Runs through authentication, company access and every endpoint the shortcodes use, so you can see exactly which Procore tool permissions your credentials have.', 'procore-connect' ) . '
'; + printf( + '', + esc_html__( 'Test connection', 'procore-connect' ) + ); + echo ''; + } + + /** + * Print the Connection tab fields. + * + * @return void + */ + private function fields_connection(): void { + $settings = Settings::all(); + $constants = Settings::credentials_are_constants(); + $has_secret = '' !== Settings::client_secret(); + + $this->row( + __( 'Authentication', 'procore-connect' ), + function () use ( $settings ) { + $modes = array( + 'client_credentials' => __( 'Service Account (Client Credentials) — recommended', 'procore-connect' ), + 'authorization_code' => __( 'User Account (Authorization Code)', 'procore-connect' ), + ); + + foreach ( $modes as $value => $label ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $value ), + checked( $settings['auth_mode'], $value, false ), + esc_html( $label ) + ); + } + + echo '' . esc_html__( 'A service account keeps working when staff change and is installed by a Procore company administrator from the App Marketplace. A user connection inherits one person\'s permissions and breaks if their access is revoked.', 'procore-connect' ) . '
'; + } + ); + + $this->row( + __( 'Environment', 'procore-connect' ), + function () use ( $settings ) { + printf( ''; + echo '' . esc_html__( 'Each environment needs its own credentials; tokens are not shared between them.', 'procore-connect' ) . '
'; + + printf( + '', + esc_html__( 'Custom login host', 'procore-connect' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_login_url'] ) + ); + + printf( + '', + esc_html__( 'Custom API host', 'procore-connect' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_api_url'] ) + ); + + echo '' . esc_html__( 'Only used when the environment is set to Custom, for regional and federal zones.', 'procore-connect' ) . '
'; + } + ); + + if ( $constants ) { + $this->row( + __( 'Credentials', 'procore-connect' ), + function () { + echo '' . esc_html__( 'Credentials are supplied by PROCORE_CONNECT_CLIENT_ID and PROCORE_CONNECT_CLIENT_SECRET in wp-config.php and are not stored in the database. Remove those constants to manage credentials here.', 'procore-connect' ) . '
'; + } + ); + } else { + $this->row( + __( 'Client ID', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['client_id'] ) + ); + } + ); + + $this->row( + __( 'Client Secret', 'procore-connect' ), + function () use ( $has_secret ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $has_secret ? Encryption::mask( Settings::client_secret() ) : __( 'Not set', 'procore-connect' ) ) + ); + + echo '' . esc_html__( 'The stored secret is never shown. Leave this blank to keep the current value.', 'procore-connect' ) . '
'; + + if ( $has_secret ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_html__( 'Delete the stored secret', 'procore-connect' ) + ); + } + + echo '' . esc_html__( 'For the strongest protection, define PROCORE_CONNECT_CLIENT_SECRET in wp-config.php instead of storing it here.', 'procore-connect' ) . '
'; + } + ); + }//end if + + $this->row( + __( 'Default company', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_company_id'] ) + ); + + printf( + ' ', + esc_html__( 'Look up companies', 'procore-connect' ) + ); + + echo ''; + echo '' . esc_html__( 'Used whenever a shortcode omits company_id. Also visible in your Procore URL: app.procore.com/companies/XXXX/.', 'procore-connect' ) . '
'; + } + ); + + $this->row( + __( 'Default project', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_project_id'] ) + ); + + echo '' . esc_html__( 'Optional. Used whenever a project-scoped shortcode omits project_id.', 'procore-connect' ) . '
'; + } + ); + } + + /** + * Print the Display tab fields. + * + * @return void + */ + private function fields_display(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Privacy', 'procore-connect' ), + 'suppress_emails', + (bool) $settings['suppress_emails'], + __( 'Never output email addresses on the front end', 'procore-connect' ), + __( 'Recommended. Procore project directories contain personal data; publishing them exposes staff and subcontractors to harvesting. When this is off, addresses are still obfuscated and each shortcode must additionally opt in with show_email="true".', 'procore-connect' ) + ); + + $this->text( + __( 'Date format', 'procore-connect' ), + 'date_format', + (string) $settings['date_format'], + __( 'Leave blank to use the site date format.', 'procore-connect' ), + (string) get_option( 'date_format', 'F j, Y' ) + ); + + $this->text( + __( 'Currency symbol', 'procore-connect' ), + 'currency_symbol', + (string) $settings['currency_symbol'], + __( 'Prefixed to monetary values such as change order totals.', 'procore-connect' ), + '$' + ); + + $this->checkbox( + __( 'Stylesheet', 'procore-connect' ), + 'load_styles', + (bool) $settings['load_styles'], + __( 'Load the Procore Connect stylesheet', 'procore-connect' ), + __( 'The stylesheet loads only on pages that actually contain Procore Connect output. Turn it off entirely if your theme styles the markup itself.', 'procore-connect' ) + ); + + $this->row( + __( 'Custom CSS', 'procore-connect' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_textarea( (string) $settings['custom_css'] ) + ); + + echo '' . esc_html__( 'Added inline after the plugin stylesheet. This survives plugin updates — editing files inside the plugin directory does not.', 'procore-connect' ) . '
'; + } + ); + + $this->row( + __( 'Template overrides', 'procore-connect' ), + function () { + echo '' . esc_html__( 'To change the markup, copy a file from the plugin\'s templates directory into your theme:', 'procore-connect' ) . '
'; + printf( + '%s → %s
' . esc_html__( 'Available templates: collection, record, field, image, map.', 'procore-connect' ) . '
'; + } + ); + } + + /** + * Print the Cache tab fields. + * + * @return void + */ + private function fields_cache(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Response cache', 'procore-connect' ), + 'enable_cache', + (bool) $settings['enable_cache'], + __( 'Cache Procore responses', 'procore-connect' ), + __( 'Strongly recommended. Procore enforces both an hourly and a ten-second rate limit; an uncached page will exhaust the quota quickly. When caching is on, a Procore outage serves the last good response instead of an error.', 'procore-connect' ) + ); + + $this->number( + __( 'Minimum cache lifetime', 'procore-connect' ), + 'cache_floor', + (int) $settings['cache_floor'], + 30, + DAY_IN_SECONDS, + __( 'Seconds. Shortcodes may request a longer lifetime but never a shorter one, so a single page cannot be configured into a rate-limit breach.', 'procore-connect' ) + ); + + $this->number( + __( 'Records per request', 'procore-connect' ), + 'per_page', + (int) $settings['per_page'], + 1, + 2000, + __( 'Procore recommends keeping this at or below 2000.', 'procore-connect' ) + ); + + $this->number( + __( 'Request timeout', 'procore-connect' ), + 'request_timeout', + (int) $settings['request_timeout'], + 5, + 60, + __( 'Seconds to wait for Procore before giving up.', 'procore-connect' ) + ); + + $this->row( + __( 'Cached responses', 'procore-connect' ), + function () { + $stats = Cache::stats(); + + printf( + '%s
', + esc_html( + sprintf( + /* translators: %d: number of cached responses. */ + _n( '%d cached response.', '%d cached responses.', (int) $stats['total'], 'procore-connect' ), + (int) $stats['total'] + ) + ) + ); + + printf( + '', + esc_html__( 'Clear cache now', 'procore-connect' ) + ); + } + ); + } + + /** + * Print the Tools tab fields. + * + * @return void + */ + private function fields_tools(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'REST proxy', 'procore-connect' ), + 'enable_rest', + (bool) $settings['enable_rest'], + __( 'Expose cached Procore data at /wp-json/procore-connect/v1/', 'procore-connect' ), + __( 'Lets themes and JavaScript read Procore data without ever seeing your credentials. Only endpoints published in the registry are reachable.', 'procore-connect' ) + ); + + $this->row( + __( 'REST access', 'procore-connect' ), + function () use ( $settings ) { + $levels = array( + 'public' => __( 'Anyone', 'procore-connect' ), + 'logged_in' => __( 'Logged-in users', 'procore-connect' ), + 'editor' => __( 'Users who can edit posts', 'procore-connect' ), + ); + + printf( ''; + } + ); + + $this->checkbox( + __( 'Block editor', 'procore-connect' ), + 'enable_blocks', + (bool) $settings['enable_blocks'], + __( 'Register Procore Connect blocks', 'procore-connect' ), + __( 'Adds a block for each shortcode with a live preview. Shortcodes keep working either way.', 'procore-connect' ) + ); + + $this->checkbox( + __( 'Diagnostics', 'procore-connect' ), + 'enable_logging', + (bool) $settings['enable_logging'], + __( 'Record API diagnostics', 'procore-connect' ), + __( 'Keeps the last 50 events for the Status tab. Credentials and tokens are redacted before anything is written.', 'procore-connect' ) + ); + + $this->checkbox( + __( 'Uninstall', 'procore-connect' ), + 'keep_data', + (bool) $settings['keep_data'], + __( 'Keep settings and cached data when the plugin is deleted', 'procore-connect' ), + __( 'Off by default, so deleting the plugin removes its credentials, tokens and cache.', 'procore-connect' ) + ); + } + + /** + * Render the Shortcodes reference tab. + * + * @return void + */ + private function render_shortcodes(): void { + echo '
' . esc_html__( 'Every shortcode below accepts the shared attributes company_id, project_id, limit, page, orderby, order, columns, template, class, title, cache and empty_text. The legacy id attribute is still accepted as an alias for project_id.', 'procore-connect' ) . '
'; + + echo '| ' . esc_html__( 'Shortcode', 'procore-connect' ) . ' | '; + echo '' . esc_html__( 'What it shows', 'procore-connect' ) . ' | '; + echo '' . esc_html__( 'Procore permission needed', 'procore-connect' ) . ' | '; + echo '
|---|---|---|
[%1$s] | ',
+ esc_html( $tag )
+ );
+ printf( '%s | ', esc_html( (string) $definition['description'] ) ); + printf( '%s | ', esc_html( $permission ) ); + echo '
' . esc_html( implode( ', ', array_keys( Endpoints::public_endpoints() ) ) ) . '
| %1$s | %2$s |
|---|
' . esc_html__( 'Nothing recorded. Turn on diagnostics under Tools to start collecting events.', 'procore-connect' ) . '
'; + + return; + } + + echo '| ' . esc_html__( 'When', 'procore-connect' ) . ' | '; + echo '' . esc_html__( 'Level', 'procore-connect' ) . ' | '; + echo '' . esc_html__( 'Event', 'procore-connect' ) . ' | '; + echo '
|---|---|---|
| %1$s | %2$s | %3$s |
' . esc_html( $description ) . '
'; + } + } + ); + } + + /** + * Print a text input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param string $value Current value. + * @param string $description Help text. + * @param string $placeholder Placeholder text. + * @return void + */ + private function text( string $label, string $key, string $value, string $description = '', string $placeholder = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $description, $placeholder ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( $value ), + esc_attr( $placeholder ) + ); + + if ( '' !== $description ) { + echo '' . esc_html( $description ) . '
'; + } + } + ); + } + + /** + * Print a number input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param int $value Current value. + * @param int $min Minimum accepted value. + * @param int $max Maximum accepted value. + * @param string $description Help text. + * @return void + */ + private function number( string $label, string $key, int $value, int $min, int $max, string $description = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $min, $max, $description ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( (string) $value ), + esc_attr( (string) $min ), + esc_attr( (string) $max ) + ); + + if ( '' !== $description ) { + echo '' . esc_html( $description ) . '
'; + } + } + ); + } +} diff --git a/src/Api/Auth/AbstractAuth.php b/src/Api/Auth/AbstractAuth.php new file mode 100644 index 0000000..009b711 --- /dev/null +++ b/src/Api/Auth/AbstractAuth.php @@ -0,0 +1,130 @@ + $body Request parameters. + * @return array| + + |
|---|
| + + | + +