diff --git a/plugin/waypoints/assets/css/admin-settings.css b/plugin/waypoints/assets/css/admin-settings.css index 1ae01c6..45acd52 100644 --- a/plugin/waypoints/assets/css/admin-settings.css +++ b/plugin/waypoints/assets/css/admin-settings.css @@ -284,6 +284,81 @@ padding-left: 0; } +.plan-your-day-api-key-field { + display: inline-flex; + align-items: center; + gap: 0.4rem; + max-width: 100%; +} + +.plan-your-day-api-key-field input.regular-text { + max-width: min(32rem, calc(100vw - 12rem)); +} + +.plan-your-day-api-key-reveal { + position: relative; + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 2.25rem; + min-width: 2.25rem; + height: 2rem; + padding: 0 !important; + line-height: 1; +} + +.plan-your-day-api-key-reveal-icon { + position: relative; + display: block; + width: 1rem; + height: 1rem; + color: currentColor; +} + +.plan-your-day-api-key-reveal-icon::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 0.9rem; + height: 0.9rem; + border: 2px solid currentColor; + border-radius: 75% 0; + transform: translate(-50%, -50%) rotate(45deg); +} + +.plan-your-day-api-key-reveal-icon::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: currentColor; + transform: translate(-50%, -50%); +} + +.plan-your-day-api-key-reveal::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 1.35rem; + height: 2px; + background: currentColor; + transform: translate(-50%, -50%) rotate(-35deg); + transform-origin: center; +} + +.plan-your-day-api-key-field.is-revealed .plan-your-day-api-key-reveal::after { + display: none; +} + +.plan-your-day-api-key-field.is-revealed input.regular-text { + background: #fff8e5; +} + .plan-your-day-admin-back-to-top { position: fixed; right: 1.5rem; diff --git a/plugin/waypoints/assets/js/admin-settings.js b/plugin/waypoints/assets/js/admin-settings.js index 94fa741..2247a40 100644 --- a/plugin/waypoints/assets/js/admin-settings.js +++ b/plugin/waypoints/assets/js/admin-settings.js @@ -1,6 +1,7 @@ (() => { const CATEGORY_ROW_SELECTOR = '[data-plan-category-row]'; const CATEGORY_DRAG_HANDLE_SELECTOR = '[data-plan-category-drag-handle]'; + const API_KEY_REVEAL_DURATION_MS = 10000; const getCategoryRows = (rows) => Array.from(rows.querySelectorAll(CATEGORY_ROW_SELECTOR)); @@ -276,6 +277,76 @@ }); }; + const initializeApiKeyRevealButtons = () => { + document.querySelectorAll('[data-plan-api-key-reveal]').forEach((button) => { + if (!(button instanceof HTMLButtonElement) || 'true' === button.dataset.planApiKeyRevealReady) { + return; + } + + const field = button.closest('.plan-your-day-api-key-field'); + const input = field?.querySelector('[data-plan-api-key-input]'); + const label = button.querySelector('[data-plan-api-key-reveal-label]'); + + if (!(field instanceof HTMLElement) || !(input instanceof HTMLInputElement)) { + return; + } + + let hideTimer = 0; + const showLabel = button.getAttribute('data-plan-show-label') || 'Show API key'; + const hideLabel = button.getAttribute('data-plan-hide-label') || 'Hide API key'; + const setButtonLabel = (text) => { + if (label instanceof HTMLElement) { + label.textContent = text; + } + + button.setAttribute('aria-label', text); + }; + const clearHideTimer = () => { + if (0 !== hideTimer) { + window.clearTimeout(hideTimer); + hideTimer = 0; + } + }; + const hideKey = () => { + clearHideTimer(); + input.type = 'password'; + button.setAttribute('aria-pressed', 'false'); + field.classList.remove('is-revealed'); + setButtonLabel(showLabel); + }; + const showKey = () => { + clearHideTimer(); + input.type = 'text'; + button.setAttribute('aria-pressed', 'true'); + field.classList.add('is-revealed'); + setButtonLabel(hideLabel); + hideTimer = window.setTimeout(hideKey, API_KEY_REVEAL_DURATION_MS); + }; + + button.dataset.planApiKeyRevealReady = 'true'; + setButtonLabel(showLabel); + + button.addEventListener('pointerdown', (event) => { + event.preventDefault(); + }); + + button.addEventListener('click', () => { + if ('text' === input.type) { + hideKey(); + } else { + showKey(); + } + + input.focus({ + preventScroll: true, + }); + }); + + input.addEventListener('blur', hideKey); + button.form?.addEventListener('submit', hideKey); + }); + }; + const initializeBackToTop = () => { const topButton = document.querySelector('[data-plan-back-to-top]'); const topTarget = document.getElementById('plan-your-day-settings-top'); @@ -322,6 +393,7 @@ const initialize = () => { initializeCategoryEditors(); + initializeApiKeyRevealButtons(); initializeBackToTop(); }; diff --git a/plugin/waypoints/plan-your-day.php b/plugin/waypoints/plan-your-day.php index 71c5c32..36f6d37 100644 --- a/plugin/waypoints/plan-your-day.php +++ b/plugin/waypoints/plan-your-day.php @@ -2,7 +2,7 @@ /** * Plugin Name: Waypoints * Description: A configurable day planning plugin for WordPress. - * Version: 1.0 + * Version: 1.0.1 * Requires at least: 6.8 * Requires PHP: 8.2 * Author: acodebeard @@ -15,7 +15,7 @@ defined( 'ABSPATH' ) || exit; -define( 'PLAN_YOUR_DAY_VERSION', '1.0' ); +define( 'PLAN_YOUR_DAY_VERSION', '1.0.1' ); define( 'PLAN_YOUR_DAY_SCHEMA_VERSION', 5 ); define( 'PLAN_YOUR_DAY_PLUGIN_FILE', __FILE__ ); define( 'PLAN_YOUR_DAY_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/plugin/waypoints/readme.txt b/plugin/waypoints/readme.txt index 19f74e6..9388017 100644 --- a/plugin/waypoints/readme.txt +++ b/plugin/waypoints/readme.txt @@ -4,7 +4,7 @@ Tags: planning, maps, wayfinding Requires at least: 6.8 Tested up to: 7.0 Requires PHP: 8.2 -Stable tag: 1.0 +Stable tag: 1.0.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -24,9 +24,6 @@ in Google Maps. Special thanks to Hagan Franks (https://github.com/hagan) and Christopher Reaume (https://github.com/datapoke) for development help. -Thanks also to Destination Kona Coast (https://destinationkonacoast.com) for -the idea that started the project. - == Installation == Release zip: @@ -111,6 +108,10 @@ architecture, settings, security, and troubleshooting notes. == Changelog == += 1.0.1 = +* Added an admin control for briefly revealing saved Google API keys while checking copied values. +* Hardened Google API key settings against browser autofill and non-key values. + = 1.0 = * Renamed the public plugin surface to Waypoints, including the text domain, REST namespace, block name, preferred shortcode, release metadata, and package artifact. * Added WordPress.org submission-readiness checks covering PHP quality, PHPStan, browser smoke tests, WordPress Plugin Check, release packaging, and metadata validation. diff --git a/plugin/waypoints/release.json b/plugin/waypoints/release.json index 5f3b1c1..2192621 100644 --- a/plugin/waypoints/release.json +++ b/plugin/waypoints/release.json @@ -1,9 +1,9 @@ { "name": "Waypoints", "slug": "waypoints", - "version": "1.0", + "version": "1.0.1", "schemaVersion": 5, - "artifact": "../../dist/waypoints-1.0.zip", + "artifact": "../../dist/waypoints-1.0.1.zip", "distribution": "github-release-zip", "notes": "Installable WordPress admin zip built from this source repository with Composer production autoload files included." } diff --git a/plugin/waypoints/src/Admin/SettingsPage.php b/plugin/waypoints/src/Admin/SettingsPage.php index e75166a..cd0bd87 100644 --- a/plugin/waypoints/src/Admin/SettingsPage.php +++ b/plugin/waypoints/src/Admin/SettingsPage.php @@ -13,6 +13,7 @@ final class SettingsPage { private const GOOGLE_TEST_TRANSIENT_PREFIX = 'plan_your_day_google_test_'; + private const GOOGLE_API_KEY_PATTERN = 'AIza[0-9A-Za-z_\\-]{35}'; private Settings $settings; private GoogleApiCache $google_api_cache; @@ -220,21 +221,21 @@ public function register(): void { 'google_maps_embed_api_key', __( 'Maps Embed API key', 'waypoints' ), __( 'Browser-facing key for Google Maps Embed previews. This key can appear in frontend iframe URLs.', 'waypoints' ), - 'password' + 'api_key' ); $this->add_field( 'google_places_api_key', __( 'Places API key', 'waypoints' ), __( 'Server-side key for Places API (New) text search and place details. This key is never sent to browser config.', 'waypoints' ), - 'password' + 'api_key' ); $this->add_field( 'google_geocoding_api_key', __( 'Geocoding API key', 'waypoints' ), __( 'Optional server-side key for Geocoding API. Leave empty to use the Places API key for geocoding.', 'waypoints' ), - 'password' + 'api_key' ); $this->add_field( @@ -988,6 +989,8 @@ public function render_field( array $args ): void { $this->render_categories_editor( $name ); } elseif ( 'interface_copy_group' === $type ) { $this->render_interface_copy_group( (string) ( $attributes['group'] ?? '' ) ); + } elseif ( 'api_key' === $type ) { + $this->render_api_key_field( $name, $id, (string) $value ); } else { printf( '', @@ -1003,6 +1006,18 @@ public function render_field( array $args ): void { } } + private function render_api_key_field( string $name, string $id, string $value ): void { + printf( + '', + esc_attr( $id ), + esc_attr( $name ), + esc_attr( $value ), + esc_attr( self::GOOGLE_API_KEY_PATTERN ), + esc_attr( __( 'Show API key', 'waypoints' ) ), + esc_attr( __( 'Hide API key', 'waypoints' ) ) + ); + } + private function render_interface_copy_group( string $group ): void { $definitions = InterfaceCopy::definitions_for_group( $group ); $copy = $this->settings->get_frontend_copy(); diff --git a/plugin/waypoints/tests/AdminSettingsScriptTest.php b/plugin/waypoints/tests/AdminSettingsScriptTest.php index 7af2b18..420c08e 100644 --- a/plugin/waypoints/tests/AdminSettingsScriptTest.php +++ b/plugin/waypoints/tests/AdminSettingsScriptTest.php @@ -25,6 +25,21 @@ public function test_category_delete_restores_keyboard_focus(): void { self::assertStringContainsString( 'focusTarget.focus({ preventScroll: true })', $script ); } + public function test_api_key_reveal_buttons_briefly_switch_inputs_to_plain_text(): void { + $script = $this->fixture( 'assets/js/admin-settings.js' ); + + self::assertStringContainsString( 'API_KEY_REVEAL_DURATION_MS', $script ); + self::assertStringContainsString( 'initializeApiKeyRevealButtons', $script ); + self::assertStringContainsString( '[data-plan-api-key-reveal]', $script ); + self::assertStringContainsString( '[data-plan-api-key-input]', $script ); + self::assertStringContainsString( "input.type = 'text'", $script ); + self::assertStringContainsString( "input.type = 'password'", $script ); + self::assertStringContainsString( "button.setAttribute('aria-pressed', 'true')", $script ); + self::assertStringContainsString( 'window.setTimeout(hideKey, API_KEY_REVEAL_DURATION_MS)', $script ); + self::assertStringContainsString( "button.addEventListener('pointerdown'", $script ); + self::assertStringContainsString( "input.addEventListener('blur', hideKey)", $script ); + } + private function fixture( string $path ): string { $content = file_get_contents( dirname( __DIR__ ) . '/' . $path ); diff --git a/plugin/waypoints/tests/PluginDisplayNameTest.php b/plugin/waypoints/tests/PluginDisplayNameTest.php index 5d3f86e..2dc3653 100644 --- a/plugin/waypoints/tests/PluginDisplayNameTest.php +++ b/plugin/waypoints/tests/PluginDisplayNameTest.php @@ -16,7 +16,7 @@ public function test_front_facing_metadata_uses_waypoints_display_name(): void { self::assertIsArray( $release ); self::assertSame( 'Waypoints', $release['name'] ?? null ); self::assertSame( 'waypoints', $release['slug'] ?? null ); - self::assertSame( '../../dist/waypoints-1.0.zip', $release['artifact'] ?? null ); + self::assertSame( '../../dist/waypoints-1.0.1.zip', $release['artifact'] ?? null ); $block = json_decode( $this->fixture( 'blocks/planner/block.json' ), true ); self::assertIsArray( $block ); diff --git a/plugin/waypoints/tests/SettingsPageTest.php b/plugin/waypoints/tests/SettingsPageTest.php index bac23fc..627ce0f 100644 --- a/plugin/waypoints/tests/SettingsPageTest.php +++ b/plugin/waypoints/tests/SettingsPageTest.php @@ -201,6 +201,18 @@ public function test_register_adds_admin_only_api_call_counter_debug_toggle(): v self::assertStringNotContainsString( 'local testing', $field['args']['description'] ?? '' ); } + public function test_register_uses_api_key_input_type_for_google_keys(): void { + $settings_page = $this->settings_page(); + + $settings_page->register(); + + foreach ( [ 'google_maps_embed_api_key', 'google_places_api_key', 'google_geocoding_api_key' ] as $key ) { + $field = $GLOBALS['plan_your_day_test_settings_fields'][ 'plan_your_day_' . $key ] ?? []; + + self::assertSame( 'api_key', $field['args']['type'] ?? null, $key ); + } + } + public function test_register_does_not_register_legacy_default_categories_toggle(): void { $settings_page = $this->settings_page(); @@ -409,6 +421,53 @@ public function test_categories_field_renders_no_saved_rows_when_saved_category_ self::assertSame( 0, preg_match_all( '/plan_your_day_settings\[categories\]\[\d+\]\[label\]/', $output ) ); } + public function test_google_api_key_field_uses_autofill_resistant_key_constraints(): void { + $api_key = 'AIza' . str_repeat( 'A', 35 ); + + update_option( + Settings::OPTION_NAME, + array_merge( + Settings::defaults(), + [ + 'google_maps_embed_api_key' => $api_key, + ] + ) + ); + + $settings_page = $this->settings_page(); + + ob_start(); + $settings_page->render_field( + [ + 'key' => 'google_maps_embed_api_key', + 'type' => 'api_key', + 'description' => '', + 'attributes' => [], + ] + ); + $output = (string) ob_get_clean(); + + self::assertStringContainsString( 'type="password"', $output ); + self::assertStringContainsString( 'value="' . $api_key . '"', $output ); + self::assertStringContainsString( 'class="plan-your-day-api-key-field"', $output ); + self::assertStringContainsString( 'data-plan-api-key-input', $output ); + self::assertStringContainsString( 'button type="button"', $output ); + self::assertStringContainsString( 'class="button plan-your-day-api-key-reveal"', $output ); + self::assertStringContainsString( 'data-plan-api-key-reveal', $output ); + self::assertStringContainsString( 'aria-controls="plan_your_day_google_maps_embed_api_key"', $output ); + self::assertStringContainsString( 'aria-pressed="false"', $output ); + self::assertStringContainsString( 'Show API key', $output ); + self::assertStringContainsString( 'plan-your-day-api-key-reveal-icon', $output ); + self::assertStringContainsString( 'autocomplete="new-password"', $output ); + self::assertStringContainsString( 'autocapitalize="none"', $output ); + self::assertStringContainsString( 'spellcheck="false"', $output ); + self::assertStringContainsString( 'pattern="AIza[0-9A-Za-z_\\-]{35}"', $output ); + self::assertStringContainsString( 'data-lpignore="true"', $output ); + self::assertStringContainsString( 'data-1p-ignore="true"', $output ); + self::assertStringContainsString( 'data-bwignore="true"', $output ); + self::assertStringNotContainsString( 'autocomplete="off"', $output ); + } + public function test_categories_field_renders_saved_default_rows(): void { update_option( Settings::OPTION_NAME,