Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions plugin/waypoints/assets/css/admin-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions plugin/waypoints/assets/js/admin-settings.js
Original file line number Diff line number Diff line change
@@ -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));

Expand Down Expand Up @@ -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,
});
Comment on lines +340 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep focus on the API-key reveal button

When a keyboard user tabs to this reveal control and presses Space/Enter, the click handler immediately moves focus into the password field. That makes the toggle's changed aria-pressed state harder to perceive and prevents using the same focused button to hide the key again; the next tab/shift-tab first blurs the input and auto-hides it. In this admin settings context, the reveal button should leave focus on the control unless the user explicitly moves it, matching the project's focus/accessibility expectations.

Useful? React with 👍 / 👎.

});

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');
Expand Down Expand Up @@ -322,6 +393,7 @@

const initialize = () => {
initializeCategoryEditors();
initializeApiKeyRevealButtons();
initializeBackToTop();
};

Expand Down
4 changes: 2 additions & 2 deletions plugin/waypoints/plan-your-day.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__ ) );
Expand Down
9 changes: 5 additions & 4 deletions plugin/waypoints/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions plugin/waypoints/release.json
Original file line number Diff line number Diff line change
@@ -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."
}
21 changes: 18 additions & 3 deletions plugin/waypoints/src/Admin/SettingsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
'<input id="%1$s" name="%2$s" type="%3$s" value="%4$s" class="regular-text" autocomplete="off" spellcheck="false" />',
Expand All @@ -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(
'<span class="plan-your-day-api-key-field"><input id="%1$s" name="%2$s" type="password" value="%3$s" class="regular-text code" autocomplete="new-password" autocapitalize="none" inputmode="text" spellcheck="false" pattern="%4$s" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" data-plan-api-key-input /><button type="button" class="button plan-your-day-api-key-reveal" aria-controls="%1$s" aria-pressed="false" data-plan-api-key-reveal data-plan-show-label="%5$s" data-plan-hide-label="%6$s"><span class="screen-reader-text" data-plan-api-key-reveal-label>%5$s</span><span class="plan-your-day-api-key-reveal-icon" aria-hidden="true"></span></button></span>',
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();
Expand Down
15 changes: 15 additions & 0 deletions plugin/waypoints/tests/AdminSettingsScriptTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down
2 changes: 1 addition & 1 deletion plugin/waypoints/tests/PluginDisplayNameTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
59 changes: 59 additions & 0 deletions plugin/waypoints/tests/SettingsPageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand Down