From d16c3c3381af3e652e933f99bed4b73762a59554 Mon Sep 17 00:00:00 2001 From: sal4sup Date: Tue, 21 Apr 2026 19:49:46 +0300 Subject: [PATCH 1/2] Add secondary API key --- assets/js/admin-settings.js | 2 + assets/js/new-api-key-banner.js | 32 ++++ languages/postnl-for-woocommerce-nl_NL.po | 28 +++ src/Admin/Api_Key_Banner.php | 210 ++++++++++++++++++++++ src/Main.php | 4 + src/Rest_API/Barcode/Key_Validator.php | 118 ++++++++++++ src/Rest_API/Base.php | 8 +- src/Shipping_Method/PostNL.php | 43 +++++ src/Shipping_Method/Settings.php | 96 ++++++++++ 9 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 assets/js/new-api-key-banner.js create mode 100644 src/Admin/Api_Key_Banner.php create mode 100644 src/Rest_API/Barcode/Key_Validator.php diff --git a/assets/js/admin-settings.js b/assets/js/admin-settings.js index 59eaa2d8..a6686e21 100644 --- a/assets/js/admin-settings.js +++ b/assets/js/admin-settings.js @@ -35,9 +35,11 @@ var value = jQuery( '#woocommerce_postnl_environment_mode' ).val(); if ( 'production' === value ) { jQuery('#woocommerce_postnl_api_keys').closest('tr').show(); + jQuery('#woocommerce_postnl_api_keys_new').closest('tr').show(); jQuery('#woocommerce_postnl_api_keys_sandbox').closest('tr').hide(); } else { jQuery('#woocommerce_postnl_api_keys').closest('tr').hide(); + jQuery('#woocommerce_postnl_api_keys_new').closest('tr').hide(); jQuery('#woocommerce_postnl_api_keys_sandbox').closest('tr').show(); } }, diff --git a/assets/js/new-api-key-banner.js b/assets/js/new-api-key-banner.js new file mode 100644 index 00000000..41ff36be --- /dev/null +++ b/assets/js/new-api-key-banner.js @@ -0,0 +1,32 @@ +( function ( $ ) { + if ( typeof window.postnlNewApiKeyBanner === 'undefined' ) { + return; + } + + var config = window.postnlNewApiKeyBanner; + + function send( $banner, mode ) { + $.post( + config.ajaxUrl, + { + action: config.action, + nonce: $banner.data( 'nonce' ), + mode: mode + } + ).always( function () { + $banner.fadeOut( 200, function () { + $( this ).remove(); + } ); + } ); + } + + $( document ).on( 'click', '.postnl-new-api-key-remind', function ( e ) { + e.preventDefault(); + send( $( this ).closest( '.postnl-new-api-key-banner' ), 'remind' ); + } ); + + $( document ).on( 'click', '.postnl-new-api-key-dismiss', function ( e ) { + e.preventDefault(); + send( $( this ).closest( '.postnl-new-api-key-banner' ), 'dismiss' ); + } ); +} )( jQuery ); diff --git a/languages/postnl-for-woocommerce-nl_NL.po b/languages/postnl-for-woocommerce-nl_NL.po index 7abb4743..03ae92de 100644 --- a/languages/postnl-for-woocommerce-nl_NL.po +++ b/languages/postnl-for-woocommerce-nl_NL.po @@ -1907,3 +1907,31 @@ msgstr "" #: src/Shipping_Method/Settings.php:401 msgid "Pickup Points" msgstr "" + +#: src/Shipping_Method/Settings.php +msgid "New API Key" +msgstr "Nieuwe API Key" + +#: src/Shipping_Method/Settings.php +msgid "Enter the new API key here, required to access the new APIs when these have been released in the plug-in." +msgstr "Vul hier je nieuwe API key in, die je toegang geeft tot de nieuwe APIs zodra deze zijn uitgerold in de plug-in." + +#: src/Shipping_Method/PostNL.php +msgid "The newly entered API key is invalid. Please check the key and enter it again." +msgstr "Ingevulde nieuwe API key ongeldig. Controleer de key en vul deze opnieuw in." + +#: src/Admin/Api_Key_Banner.php +msgid "PostNL: New API Key required" +msgstr "PostNL: nieuwe API key vereist" + +#: src/Admin/Api_Key_Banner.php +msgid "Important: In the latest update of the plug-in, an additional API key field has been added to the account configuration of the PostNL plug-in. This field must be filled in with the new API key that can be obtained via the Self Service module on the PostNL Business Portal. This API key is required to gain access to the new APIs that will be rolled out in a future update of the plug-in. It is very important that this key is entered before the relevant update is performed; otherwise, no connection can be made to the new PostNL APIs, and it will not be possible to create labels or use checkout features such as delivery days and pickup points." +msgstr "Let op: er is in de laatste update van de plug-in een extra API key veld toegevoegd aan de accountconfiguratie van de PostNL plug-in. In dit veld moet de nieuwe API key ingevuld worden die je via de Self Service module op het Business Portal van PostNL kunt verkrijgen. Deze API key is nodig om toegang te krijgen tot de nieuwe APIs die zullen worden uitgerold in een toekomstige update van de plug-in. Het is van groot belang dat deze key ingevuld wordt voordat de betreffende update wordt uitgevoerd, omdat er anders geen verbinding gemaakt kan worden met de nieuwe PostNL APIs en er dus geen labels aangemaakt kunnen worden of gebruik gemaakt kan worden van de check-out functies zoals bezorgdagen en afhaalpunten." + +#: src/Admin/Api_Key_Banner.php +msgid "Remind me later" +msgstr "Herinner me later" + +#: src/Admin/Api_Key_Banner.php +msgid "Dismiss permanently" +msgstr "Definitief verbergen" diff --git a/src/Admin/Api_Key_Banner.php b/src/Admin/Api_Key_Banner.php new file mode 100644 index 00000000..e0d88574 --- /dev/null +++ b/src/Admin/Api_Key_Banner.php @@ -0,0 +1,210 @@ +id + && isset( $_GET['section'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + && POSTNL_SETTINGS_ID === sanitize_text_field( wp_unslash( $_GET['section'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ) { + return true; + } + + if ( 'edit-shop_order' === $screen->id ) { + return true; + } + + if ( 'woocommerce_page_wc-orders' === $screen->id ) { + return true; + } + + return false; + } + + /** + * Should the banner be visible right now for this user? + */ + protected function should_show() { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return false; + } + + if ( ! $this->is_target_screen() ) { + return false; + } + + $settings = Settings::get_instance(); + $new_key = $settings->get_api_key_new(); + + // If a valid new key has already been entered we have what we need. + if ( '' !== $new_key && $settings->is_api_key_new_validated() ) { + return false; + } + + $user_id = get_current_user_id(); + if ( get_user_meta( $user_id, self::META_DISMISSED, true ) ) { + return false; + } + + if ( get_user_meta( $user_id, self::META_REMIND_LATER, true ) ) { + return false; + } + + return true; + } + + /** + * Render the banner markup. + */ + public function maybe_render() { + if ( ! $this->should_show() ) { + return; + } + + $nonce = wp_create_nonce( self::NONCE_ACTION ); + ?> +
+

+

get_message() ); ?>

+

+ + +

+
+ is_target_screen() ) { + return; + } + + wp_enqueue_script( + 'postnl-new-api-key-banner', + POSTNL_WC_PLUGIN_DIR_URL . '/assets/js/new-api-key-banner.js', + array( 'jquery' ), + POSTNL_WC_VERSION, + true + ); + + wp_localize_script( + 'postnl-new-api-key-banner', + 'postnlNewApiKeyBanner', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'action' => self::AJAX_ACTION, + ) + ); + } + + /** + * AJAX handler for both dismiss modes. + */ + public function handle_ajax_dismiss() { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( array( 'message' => 'forbidden' ), 403 ); + } + + check_ajax_referer( self::NONCE_ACTION, 'nonce' ); + + $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : ''; + $user_id = get_current_user_id(); + + if ( 'remind' === $mode ) { + update_user_meta( $user_id, self::META_REMIND_LATER, time() ); + wp_send_json_success(); + } + + if ( 'dismiss' === $mode ) { + update_user_meta( $user_id, self::META_DISMISSED, 1 ); + wp_send_json_success(); + } + + wp_send_json_error( array( 'message' => 'invalid mode' ), 400 ); + } + + /** + * Wipe the "remind me later" flag whenever the user logs in, so the + * banner comes back the next session. + * + * @param string $user_login Username. + * @param \WP_User $user Logged-in user. + */ + public function clear_remind_later_on_login( $user_login, $user ) { + unset( $user_login ); + + if ( $user instanceof \WP_User ) { + delete_user_meta( $user->ID, self::META_REMIND_LATER ); + } + } +} diff --git a/src/Main.php b/src/Main.php index 477501dc..06587478 100644 --- a/src/Main.php +++ b/src/Main.php @@ -194,6 +194,10 @@ public function init() { $this->load_fill_in_with_postnl_settings(); $this->get_frontend(); $this->get_product_editor(); + + if ( is_admin() ) { + new Admin\Api_Key_Banner(); + } } /** diff --git a/src/Rest_API/Barcode/Key_Validator.php b/src/Rest_API/Barcode/Key_Validator.php new file mode 100644 index 00000000..81faa430 --- /dev/null +++ b/src/Rest_API/Barcode/Key_Validator.php @@ -0,0 +1,118 @@ + '3S', + 'Serie' => '000000000-999999999', + 'CustomerCode' => $customer_code, + 'CustomerNumber' => $customer_num, + 'Range' => $range, + ), + $endpoint + ); + + $response = wp_remote_get( + $url, + array( + 'timeout' => 15, + 'headers' => array( + 'apikey' => $api_key, + 'accept' => 'application/json', + 'Content-Type' => 'application/json', + 'SourceSystem' => '35', + ), + ) + ); + + $logger = Main::get_logger(); + if ( $logger ) { + $logger->write( 'PostNL new API key validation request.' ); + } + + if ( is_wp_error( $response ) ) { + return new \WP_Error( 'postnl_key_http_error', $response->get_error_message() ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( 401 === $code || 403 === $code ) { + return new \WP_Error( 'postnl_key_unauthorized', __( 'The API key was rejected by PostNL.', 'postnl-for-woocommerce' ) ); + } + + if ( is_array( $data ) ) { + if ( ! empty( $data['fault'] ) ) { + return new \WP_Error( 'postnl_key_fault', $data['fault']['faultstring'] ?? __( 'Unknown API fault.', 'postnl-for-woocommerce' ) ); + } + if ( ! empty( $data['Errors'] ) ) { + $first = array_shift( $data['Errors'] ); + return new \WP_Error( 'postnl_key_error', $first['Description'] ?? $first['ErrorMsg'] ?? __( 'Unknown API error.', 'postnl-for-woocommerce' ) ); + } + if ( ! empty( $data['Error'] ) ) { + return new \WP_Error( 'postnl_key_error', $data['Error']['ErrorMessage'] ?? __( 'Unknown API error.', 'postnl-for-woocommerce' ) ); + } + } + + if ( $code < 200 || $code >= 300 ) { + return new \WP_Error( 'postnl_key_http_status', sprintf( __( 'Unexpected HTTP status %d from PostNL.', 'postnl-for-woocommerce' ), $code ) ); + } + + if ( is_array( $data ) && isset( $data['Barcode'] ) ) { + return true; + } + + return new \WP_Error( 'postnl_key_unexpected', __( 'Unexpected response from PostNL Barcode API.', 'postnl-for-woocommerce' ) ); + } +} diff --git a/src/Rest_API/Base.php b/src/Rest_API/Base.php index 019c8e3e..fa337387 100644 --- a/src/Rest_API/Base.php +++ b/src/Rest_API/Base.php @@ -154,7 +154,12 @@ public function get_api_url() { * Set API key value. */ public function set_api_key() { - $this->api_key = ( true === $this->is_sandbox ) ? $this->settings->get_api_key_sandbox() : $this->settings->get_api_key(); + if ( true === $this->is_sandbox ) { + $this->api_key = $this->settings->get_api_key_sandbox(); + return; + } + + $this->api_key = $this->settings->get_effective_api_key(); } /** @@ -178,6 +183,7 @@ public function get_api_key() { public function get_basic_headers_args() { return array( 'apikey' => $this->get_api_key(), + 'NewKey' => $this->settings->get_new_key_header_value(), 'accept' => 'application/json', 'Content-Type' => 'application/json', 'SourceSystem' => '35', diff --git a/src/Shipping_Method/PostNL.php b/src/Shipping_Method/PostNL.php index 08d9e7a7..d1a2f9f6 100644 --- a/src/Shipping_Method/PostNL.php +++ b/src/Shipping_Method/PostNL.php @@ -7,6 +7,7 @@ namespace PostNLWooCommerce\Shipping_Method; +use PostNLWooCommerce\Rest_API\Barcode\Key_Validator; use PostNLWooCommerce\Utils; use WC_Admin_Settings; @@ -73,6 +74,48 @@ public function process_admin_options() { if ( 'yes' !== $this->get_option( 'enable_pickup_points' ) ) { $this->update_option( 'default_checkout_tab', 'delivery_day' ); } + + $this->process_new_api_key_validation(); + } + + /** + * Validate the "New API Key" field whenever settings are saved. + * + * Only runs in production mode. Performs a live Barcode API call with + * the candidate key. Success flips the validated flag on so the plugin + * starts routing traffic through the new key; failure leaves the old + * key in use and surfaces an error to the merchant. + */ + protected function process_new_api_key_validation() { + $settings = Settings::get_instance(); + + $env = $this->get_option( 'environment_mode' ); + if ( 'production' !== $env ) { + return; + } + + $new_key = trim( (string) $this->get_option( 'api_keys_new' ) ); + $original = trim( (string) $this->get_option( 'api_keys' ) ); + + if ( '' === $new_key || $new_key === $original ) { + $settings->set_api_key_new_validated( false ); + return; + } + + $customer_code = $this->get_option( 'customer_code' ); + $customer_num = $this->get_option( 'customer_num' ); + + $result = Key_Validator::validate( $new_key, $customer_code, $customer_num ); + + if ( is_wp_error( $result ) ) { + $settings->set_api_key_new_validated( false ); + WC_Admin_Settings::add_error( + esc_html__( 'The newly entered API key is invalid. Please check the key and enter it again.', 'postnl-for-woocommerce' ) + ); + return; + } + + $settings->set_api_key_new_validated( true ); } /** diff --git a/src/Shipping_Method/Settings.php b/src/Shipping_Method/Settings.php index 3b6d674e..01923335 100644 --- a/src/Shipping_Method/Settings.php +++ b/src/Shipping_Method/Settings.php @@ -103,6 +103,14 @@ public function get_setting_fields() { 'default' => '', 'placeholder' => '', ), + 'api_keys_new' => array( + 'title' => esc_html__( 'New API Key', 'postnl-for-woocommerce' ), + 'type' => 'text', + 'description' => esc_html__( 'Enter the new API key here, required to access the new APIs when these have been released in the plug-in.', 'postnl-for-woocommerce' ), + 'desc_tip' => true, + 'default' => '', + 'placeholder' => '', + ), 'enable_logging' => array( 'title' => esc_html__( 'Logging', 'postnl-for-woocommerce' ), 'type' => 'checkbox', @@ -803,6 +811,94 @@ public function get_api_key_sandbox() { return $this->get_country_option( 'api_keys_sandbox', '' ); } + /** + * Option name that stores whether the new API key has been validated + * against the PostNL API. Kept as a standalone option rather than a + * visible setting so merchants cannot toggle it manually. + */ + const NEW_API_KEY_VALIDATED_OPTION = 'postnl_api_keys_new_validated'; + + /** + * Get the raw value of the new API key as entered by the merchant. + * + * @return string + */ + public function get_api_key_new() { + return trim( (string) $this->get_country_option( 'api_keys_new', '' ) ); + } + + /** + * Whether the new API key has passed validation against the PostNL API. + * + * @return bool + */ + public function is_api_key_new_validated() { + return 'yes' === get_option( self::NEW_API_KEY_VALIDATED_OPTION, '' ); + } + + /** + * Mark the new API key as validated or not. Called after the save-time + * test call resolves. + * + * @param bool $validated Validation outcome. + */ + public function set_api_key_new_validated( $validated ) { + update_option( self::NEW_API_KEY_VALIDATED_OPTION, $validated ? 'yes' : 'no' ); + } + + /** + * Return the API key the plugin should actually send to PostNL for + * production traffic. Falls back to the original key whenever the new + * key is empty, identical, or has not been validated. + * + * @return string + */ + public function get_effective_api_key() { + $original = trim( (string) $this->get_api_key() ); + $new_key = $this->get_api_key_new(); + + if ( '' === $new_key ) { + return $original; + } + + if ( $new_key === $original ) { + return $original; + } + + if ( ! $this->is_api_key_new_validated() ) { + return $original; + } + + return $new_key; + } + + /** + * Value for the NewKey header sent on every outgoing API call. Used by + * PostNL to track adoption of the new key ahead of the API migration. + * + * - "No" : the new-key field is empty. + * - "Same" : the new-key field matches the original key. + * - "Yes" : a distinct new key has been entered and validated. + * + * Entered-but-not-yet-validated keys report "No" because the plugin is + * still sending traffic with the original key. + * + * @return string + */ + public function get_new_key_header_value() { + $new_key = $this->get_api_key_new(); + if ( '' === $new_key ) { + return 'No'; + } + + $original = trim( (string) $this->get_api_key() ); + if ( $new_key === $original ) { + return 'Same'; + } + + return $this->is_api_key_new_validated() ? 'Yes' : 'No'; + } + /** * Get customer number from the settings. * From d84bce3a94a290ff86f7a128c72dfc013adc1c91 Mon Sep 17 00:00:00 2001 From: sal4sup Date: Fri, 1 May 2026 20:31:10 +0300 Subject: [PATCH 2/2] Fix NewKey semantics --- languages/postnl-for-woocommerce-nl_NL.po | 4 ++ src/Rest_API/Barcode/Key_Validator.php | 2 + src/Shipping_Method/PostNL.php | 8 ++++ src/Shipping_Method/Settings.php | 52 +++++++++++++++++------ 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/languages/postnl-for-woocommerce-nl_NL.po b/languages/postnl-for-woocommerce-nl_NL.po index 03ae92de..9cfd3c66 100644 --- a/languages/postnl-for-woocommerce-nl_NL.po +++ b/languages/postnl-for-woocommerce-nl_NL.po @@ -1920,6 +1920,10 @@ msgstr "Vul hier je nieuwe API key in, die je toegang geeft tot de nieuwe APIs z msgid "The newly entered API key is invalid. Please check the key and enter it again." msgstr "Ingevulde nieuwe API key ongeldig. Controleer de key en vul deze opnieuw in." +#: src/Shipping_Method/PostNL.php +msgid "Please fill in Customer Code and Customer Number first to validate the new API key." +msgstr "Vul eerst de Klantcode en het Klantnummer in om de nieuwe API key te valideren." + #: src/Admin/Api_Key_Banner.php msgid "PostNL: New API Key required" msgstr "PostNL: nieuwe API key vereist" diff --git a/src/Rest_API/Barcode/Key_Validator.php b/src/Rest_API/Barcode/Key_Validator.php index 81faa430..7839a1bb 100644 --- a/src/Rest_API/Barcode/Key_Validator.php +++ b/src/Rest_API/Barcode/Key_Validator.php @@ -12,6 +12,7 @@ namespace PostNLWooCommerce\Rest_API\Barcode; use PostNLWooCommerce\Main; +use PostNLWooCommerce\Shipping_Method\Settings; use PostNLWooCommerce\Utils; if ( ! defined( 'ABSPATH' ) ) { @@ -68,6 +69,7 @@ public static function validate( $api_key, $customer_code, $customer_num ) { 'timeout' => 15, 'headers' => array( 'apikey' => $api_key, + 'NewKey' => Settings::get_instance()->get_new_key_header_value(), 'accept' => 'application/json', 'Content-Type' => 'application/json', 'SourceSystem' => '35', diff --git a/src/Shipping_Method/PostNL.php b/src/Shipping_Method/PostNL.php index d1a2f9f6..543ec216 100644 --- a/src/Shipping_Method/PostNL.php +++ b/src/Shipping_Method/PostNL.php @@ -109,6 +109,14 @@ protected function process_new_api_key_validation() { if ( is_wp_error( $result ) ) { $settings->set_api_key_new_validated( false ); + + if ( 'postnl_missing_customer_data' === $result->get_error_code() ) { + WC_Admin_Settings::add_error( + esc_html__( 'Please fill in Customer Code and Customer Number first to validate the new API key.', 'postnl-for-woocommerce' ) + ); + return; + } + WC_Admin_Settings::add_error( esc_html__( 'The newly entered API key is invalid. Please check the key and enter it again.', 'postnl-for-woocommerce' ) ); diff --git a/src/Shipping_Method/Settings.php b/src/Shipping_Method/Settings.php index 01923335..b9bb1276 100644 --- a/src/Shipping_Method/Settings.php +++ b/src/Shipping_Method/Settings.php @@ -812,11 +812,12 @@ public function get_api_key_sandbox() { } /** - * Option name that stores whether the new API key has been validated - * against the PostNL API. Kept as a standalone option rather than a - * visible setting so merchants cannot toggle it manually. + * Option storing the SHA-256 hash of the new API key value that last + * passed validation. Storing a hash (rather than a global yes/no flag) + * binds the validated state to the exact key value, so any out-of-band + * edit or partial save naturally invalidates the flag. */ - const NEW_API_KEY_VALIDATED_OPTION = 'postnl_api_keys_new_validated'; + const NEW_API_KEY_VALIDATED_HASH_OPTION = 'postnl_api_keys_new_validated_hash'; /** * Get the raw value of the new API key as entered by the merchant. @@ -828,22 +829,45 @@ public function get_api_key_new() { } /** - * Whether the new API key has passed validation against the PostNL API. + * Whether the currently-entered new API key matches the key that last + * passed validation against the PostNL API. * * @return bool */ public function is_api_key_new_validated() { - return 'yes' === get_option( self::NEW_API_KEY_VALIDATED_OPTION, '' ); + $key = $this->get_api_key_new(); + if ( '' === $key ) { + return false; + } + + $stored = (string) get_option( self::NEW_API_KEY_VALIDATED_HASH_OPTION, '' ); + if ( '' === $stored ) { + return false; + } + + return hash_equals( $stored, hash( 'sha256', $key ) ); } /** - * Mark the new API key as validated or not. Called after the save-time - * test call resolves. + * Record that the currently-entered new API key has passed validation, + * or clear the flag entirely. The hash binds the flag to the exact key + * value the merchant just successfully tested. * * @param bool $validated Validation outcome. */ public function set_api_key_new_validated( $validated ) { - update_option( self::NEW_API_KEY_VALIDATED_OPTION, $validated ? 'yes' : 'no' ); + if ( ! $validated ) { + delete_option( self::NEW_API_KEY_VALIDATED_HASH_OPTION ); + return; + } + + $key = $this->get_api_key_new(); + if ( '' === $key ) { + delete_option( self::NEW_API_KEY_VALIDATED_HASH_OPTION ); + return; + } + + update_option( self::NEW_API_KEY_VALIDATED_HASH_OPTION, hash( 'sha256', $key ) ); } /** @@ -878,10 +902,12 @@ public function get_effective_api_key() { * * - "No" : the new-key field is empty. * - "Same" : the new-key field matches the original key. - * - "Yes" : a distinct new key has been entered and validated. + * - "Yes" : a distinct new key has been entered. * - * Entered-but-not-yet-validated keys report "No" because the plugin is - * still sending traffic with the original key. + * Reports "Yes" as soon as the merchant has typed in a key that is + * different from the original, regardless of whether our save-time + * validation has passed yet. The actual key swap (production traffic + * using the new key) is gated separately by is_api_key_new_validated(). * * @return string */ @@ -896,7 +922,7 @@ public function get_new_key_header_value() { return 'Same'; } - return $this->is_api_key_new_validated() ? 'Yes' : 'No'; + return 'Yes'; } /**