PR Vision v0.2.3: UI-editable provider API key, encrypted + write-only
Add PRV_Key_Store (resolver), PRV_Crypto_Helper (libsodium/OpenSSL), PRV_Key_Manager_Renderer (write-only Settings card), PRV_Key_Test_Ajax. Key resolver: PRV_OPENROUTER_API_KEY constant > prv_provider_key_enc option > none. All probe + test paths read key through PRV_Key_Store::get_key(). Security: - Ciphertext only in prv_provider_key_enc (hex nonce:hex ciphertext). - Enc key from SHA-256(AUTH_KEY+SECURE_AUTH_KEY) — never stored. - Plaintext never in options, logs, exceptions, page source, REST, or JS. - manage_options + nonce on set/remove/test. wp-config precedence enforced. - write-only UI: password input always renders empty; status text only. - uninstall.php prv_ wildcard DELETE covers prv_provider_key_enc. Files: 4 new (all ≤300 lines), 14 modified. Tests: test-key-store.php (encrypt/decrypt round-trip, wrong-key fails silently, resolver precedence, write-only render, Remove, constant wins). ARCHITECTURE/CONVENTIONS/CONTEXT/ CHANGELOG all updated in this commit. Agent-Session: 2026-06-15-pr-vision-v0.2.3-api-key-ui
This commit is contained in:
213
includes/core/class-prv-key-manager-renderer.php
Normal file
213
includes/core/class-prv-key-manager-renderer.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
/**
|
||||
* Renderer for the Provider API Key manager card on the Settings page.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the write-only Provider API Key manager card.
|
||||
*
|
||||
* Outputs only the key SOURCE (constant / admin / not set) and the
|
||||
* last-run API status. The stored key value is NEVER included in any
|
||||
* output: not in HTML, not in JS, not in data attributes.
|
||||
*
|
||||
* Three states:
|
||||
* - SOURCE_CONSTANT: constant defined → lock the input, show note.
|
||||
* - SOURCE_OPTION: admin key stored → show "Set via admin" + Replace/Remove.
|
||||
* - SOURCE_NONE: no key → show "Not set" + Set form.
|
||||
*
|
||||
* The form submits to admin-post.php (prv_key_set or prv_key_remove),
|
||||
* nonce-protected. The password input renders empty on every page load.
|
||||
*
|
||||
* Who triggers: PRV_Settings_Renderer::render_key_manager_card().
|
||||
* Dependencies: PRV_Key_Store, PRV_Settings_Page (nonce constants).
|
||||
*
|
||||
* @see class-prv-key-store.php -- Source detection.
|
||||
* @see class-prv-settings-renderer.php -- Calls render().
|
||||
* @see class-prv-settings-page.php -- Nonce constants + AJAX action.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Key_Manager_Renderer {
|
||||
|
||||
/**
|
||||
* Render the full Provider API Key card.
|
||||
*
|
||||
* Side effects: Outputs HTML directly. Contains no key value.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
$source = PRV_Key_Store::get_source();
|
||||
$is_const = PRV_Key_Store::SOURCE_CONSTANT === $source;
|
||||
$is_set_opt = PRV_Key_Store::SOURCE_OPTION === $source;
|
||||
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
|
||||
$last_check = (string) get_option( 'prv_api_key_last_check', '' );
|
||||
|
||||
echo '<div class="prv-card" id="prv-key-card">';
|
||||
echo '<h2>' . esc_html__( 'Provider API Key', 'pr-vision' ) . '</h2>';
|
||||
|
||||
$this->render_status_badge( $source, $status, $last_check );
|
||||
$this->render_key_form( $is_const, $is_set_opt );
|
||||
$this->render_test_button( $source );
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the read-only source + health status badge.
|
||||
*
|
||||
* @param string $source One of PRV_Key_Store::SOURCE_* constants.
|
||||
* @param string $api_status Last stored API status (ok/failed/unknown).
|
||||
* @param string $last_check ISO date of last status update.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_status_badge( string $source, string $api_status, string $last_check ): void {
|
||||
echo '<div class="prv-field">';
|
||||
|
||||
if ( PRV_Key_Store::SOURCE_CONSTANT === $source ) {
|
||||
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Set via wp-config (takes precedence)', 'pr-vision' );
|
||||
echo '</span>';
|
||||
} elseif ( PRV_Key_Store::SOURCE_OPTION === $source ) {
|
||||
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Set via admin', 'pr-vision' );
|
||||
echo '</span>';
|
||||
} else {
|
||||
echo '<span class="prv-api-status prv-api-undef" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Not set', 'pr-vision' );
|
||||
echo '</span>';
|
||||
}
|
||||
|
||||
// Last-run health state.
|
||||
if ( '' !== $last_check && PRV_Key_Store::SOURCE_NONE !== $source ) {
|
||||
if ( 'failed' === $api_status ) {
|
||||
echo ' <span class="prv-api-status prv-api-fail">' . esc_html__( 'Last run: failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
|
||||
} elseif ( 'ok' === $api_status ) {
|
||||
echo ' <span class="prv-api-status prv-api-ok">' . esc_html__( 'Last run: OK', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the set/replace/remove key form.
|
||||
*
|
||||
* The password input always renders empty. When constant is set, the
|
||||
* input is disabled with an explanatory note.
|
||||
*
|
||||
* @param bool $is_const Whether the constant is currently defined.
|
||||
* @param bool $is_set_opt Whether an admin-stored (option) key exists.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_key_form( bool $is_const, bool $is_set_opt ): void {
|
||||
$form_url = esc_url( admin_url( 'admin-post.php' ) );
|
||||
echo '<form method="post" action="' . $form_url . '" id="prv-key-set-form" autocomplete="off">';
|
||||
echo '<input type="hidden" name="action" value="prv_key_set">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
|
||||
|
||||
echo '<div class="prv-field" style="margin-top:12px">';
|
||||
echo '<label class="prv-label" for="prv_api_key">';
|
||||
echo esc_html( $is_set_opt
|
||||
? __( 'Replace key (leave empty to keep current)', 'pr-vision' )
|
||||
: __( 'Set key', 'pr-vision' )
|
||||
);
|
||||
echo '</label>';
|
||||
|
||||
$disabled = $is_const ? ' disabled aria-disabled="true"' : '';
|
||||
// Input always renders empty — never outputs stored key.
|
||||
echo '<input type="password" id="prv_api_key" name="prv_api_key" '
|
||||
. 'class="prv-input" style="max-width:420px" '
|
||||
. 'value="" autocomplete="new-password" '
|
||||
. 'placeholder="' . esc_attr( $is_const ? __( 'Managed via wp-config — cannot override', 'pr-vision' ) : __( 'sk-or-…', 'pr-vision' ) ) . '"'
|
||||
. $disabled . '>';
|
||||
|
||||
if ( $is_const ) {
|
||||
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">'
|
||||
. esc_html__( 'PRV_OPENROUTER_API_KEY is defined in wp-config.php and takes precedence. Remove it from wp-config to manage the key here.', 'pr-vision' )
|
||||
. '</p>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
|
||||
if ( ! $is_const ) {
|
||||
echo '<button type="submit" class="prv-btn prv-btn-primary" style="margin-top:4px">'
|
||||
. esc_html( $is_set_opt ? __( 'Replace key', 'pr-vision' ) : __( 'Set key', 'pr-vision' ) )
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
echo '</form>';
|
||||
|
||||
// Remove key form (only when admin option is set, not constant).
|
||||
if ( $is_set_opt && ! $is_const ) {
|
||||
echo '<form method="post" action="' . $form_url . '" id="prv-key-remove-form" style="margin-top:8px">';
|
||||
echo '<input type="hidden" name="action" value="prv_key_remove">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
|
||||
echo '<button type="submit" class="prv-btn prv-btn-danger" '
|
||||
. 'onclick="return confirm(\'' . esc_js( __( 'Remove the stored API key? The next probe run will fail unless PRV_OPENROUTER_API_KEY is set in wp-config.php.', 'pr-vision' ) ) . '\')">'
|
||||
. esc_html__( 'Remove key', 'pr-vision' )
|
||||
. '</button>';
|
||||
echo '</form>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Test key button and aria-live result area.
|
||||
*
|
||||
* Only shown when a key source is available. The test sends one cheap
|
||||
* probe via AJAX and reports valid/invalid without leaking the key.
|
||||
*
|
||||
* @param string $source Current key source.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_test_button( string $source ): void {
|
||||
if ( PRV_Key_Store::SOURCE_NONE === $source ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_KEY_TEST );
|
||||
$ajax_url = admin_url( 'admin-ajax.php' );
|
||||
|
||||
echo '<div style="margin-top:12px;display:flex;align-items:center;gap:12px">';
|
||||
echo '<button type="button" id="prv-test-key-btn" class="prv-btn prv-btn-ghost" '
|
||||
. 'data-ajax-url="' . esc_url( $ajax_url ) . '" '
|
||||
. 'data-nonce="' . esc_attr( $nonce ) . '">'
|
||||
. esc_html__( 'Test key', 'pr-vision' )
|
||||
. '</button>';
|
||||
echo '<span id="prv-test-key-result" aria-live="polite" role="status" style="font-size:13px"></span>';
|
||||
echo '</div>';
|
||||
|
||||
// Inline JS — only a button wirer, no key value in JS.
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>(function(){
|
||||
var btn=document.getElementById("prv-test-key-btn");
|
||||
if(!btn)return;
|
||||
btn.addEventListener("click",function(){
|
||||
var res=document.getElementById("prv-test-key-result");
|
||||
btn.disabled=true;btn.setAttribute("aria-busy","true");
|
||||
if(res){res.textContent="Testing…";}
|
||||
fetch(btn.dataset.ajaxUrl,{
|
||||
method:"POST",
|
||||
headers:{"Content-Type":"application/x-www-form-urlencoded"},
|
||||
body:new URLSearchParams({action:"prv_test_key",prv_nonce:btn.dataset.nonce})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
btn.disabled=false;btn.removeAttribute("aria-busy");
|
||||
if(d.success){
|
||||
if(res){res.textContent="✓ "+(d.data?d.data.message:"Key valid");res.style.color="var(--prv-lime)";}
|
||||
}else{
|
||||
if(res){res.textContent="✗ "+(d.data?d.data.message:"Key invalid");res.style.color="var(--prv-red-chip)";}
|
||||
}
|
||||
}).catch(function(){
|
||||
btn.disabled=false;btn.removeAttribute("aria-busy");
|
||||
if(res){res.textContent="Request error";res.style.color="var(--prv-red-chip)";}
|
||||
});
|
||||
});
|
||||
})();</script>';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user