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,